diff --git a/.github/scripts/verify-built-in-genesis.sh b/.github/scripts/verify-built-in-genesis.sh new file mode 100644 index 000000000..ddfe8e6a6 --- /dev/null +++ b/.github/scripts/verify-built-in-genesis.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +chainspec="$repo_root/crates/node/src/chainspec.rs" +key_source="$repo_root/crates/release-verify/src/key.rs" +expected_fingerprint=0A6D05E5DD98069BA184ED8304A68D620D5208FD + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +awk ' + /-----BEGIN PGP PUBLIC KEY BLOCK-----/ { + sub(/^.*"-----BEGIN/, "-----BEGIN") + print + in_key = 1 + next + } + in_key { + if (/-----END PGP PUBLIC KEY BLOCK-----/) { + sub(/";.*/, "") + print + exit + } + print + } +' "$key_source" > "$tmp_dir/release-key.asc" + +actual_fingerprint=$( + gpg --batch --with-colons --import-options show-only --import "$tmp_dir/release-key.asc" 2>/dev/null | + awk -F: '$1 == "fpr" { print $10; exit }' +) +if [[ "$actual_fingerprint" != "$expected_fingerprint" ]]; then + echo "embedded release key fingerprint mismatch: $actual_fingerprint" >&2 + exit 1 +fi + +export GNUPGHOME="$tmp_dir/gnupg" +mkdir -m 700 "$GNUPGHOME" +gpg --batch --quiet --import "$tmp_dir/release-key.asc" + +assets=( + 'fluent-devnet|v0.5.7|genesis-v0.5.7.json.gz|91b9a427805d45dd14e46a0cd517bcc85f350fe7dfc38fa96f6ff0ebf5e864da' + 'fluent-testnet|v0.3.4-dev|genesis-v0.3.4-dev.json.gz|8cd30358c5664375e6739bc48302445e7ee10fd0158bedb788505e5c590983bd' + 'fluent-mainnet|v1.0.0|genesis-mainnet-v1.0.0.json.gz|72cb4b3b7b15de952bd1094281a1f2430cb711bc473a0520f92aa3e2b1bdb643' +) + +for spec in "${assets[@]}"; do + IFS='|' read -r network tag name expected_sha256 <<< "$spec" + grep -Fq "$tag" "$chainspec" + grep -Fq "$expected_sha256" "$chainspec" + + base_url="https://github.com/fluentlabs-xyz/fluentbase/releases/download/$tag" + curl --fail --location --silent --show-error --retry 3 --retry-all-errors \ + "$base_url/$name" --output "$tmp_dir/$name" + curl --fail --location --silent --show-error --retry 3 --retry-all-errors \ + "$base_url/$name.asc" --output "$tmp_dir/$name.asc" + + actual_sha256=$(sha256sum "$tmp_dir/$name" | awk '{print $1}') + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + echo "$network: sha256 mismatch: expected $expected_sha256, got $actual_sha256" >&2 + exit 1 + fi + gpg --batch --verify "$tmp_dir/$name.asc" "$tmp_dir/$name" + echo "$network: authenticated $name ($actual_sha256)" +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0549c2b5e..6aa5a8194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check release workflow hardening run: ./.github/scripts/check-release-supply-chain.sh .github/workflows/release.yml + - name: Authenticate built-in genesis assets + run: bash ./.github/scripts/verify-built-in-genesis.sh tests: name: Tests (${{ matrix.name }}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d327db93..44c9a5d6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,7 +125,13 @@ jobs: for i in $(seq 1 40); do if docker pull "$IMAGE"; then echo "Builder image is available" - digest="$(docker image inspect "$IMAGE" --format '{{range .RepoDigests}}{{println .}}{{end}}{{.Id}}' | sed '/^$/d' | head -n1)" + # Only a registry digest proves where the image came from; the local image ID + # does not, and the build refuses to run an image it cannot attribute. + digest="$(docker image inspect "$IMAGE" --format '{{range .RepoDigests}}{{println .}}{{end}}' | sed '/^$/d' | head -n1)" + if [[ -z "$digest" ]]; then + echo "Builder image $IMAGE has no registry digest" >&2 + exit 1 + fi echo "digest=$digest" >> "$GITHUB_OUTPUT" exit 0 fi @@ -144,6 +150,8 @@ jobs: FLUENTBASE_BUILD_DOCKER_IMAGE: ${{ steps.build_image.outputs.image }} FLUENTBASE_BUILD_DOCKER_TAG: ${{ steps.build_image.outputs.tag }} FLUENTBASE_BUILD_DOCKER_DIGEST: ${{ steps.build_image.outputs.digest }} + # A dry run builds the image locally, so it has no digest to verify. + FLUENTBASE_BUILD_ALLOW_UNVERIFIED_IMAGE: ${{ github.event.inputs.dry_run == 'true' }} run: | cargo build --release --locked sha256sum \ diff --git a/.gitignore b/.gitignore index 185787df2..bdc56f389 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ genesis-*.json node_modules evm-e2e/tests/ datadir +graphify-out/ diff --git a/Cargo.lock b/Cargo.lock index c7f9173d2..2ad861e5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ + "bytes", "crypto-common 0.1.7", "generic-array 0.14.7", ] @@ -71,6 +72,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1256,6 +1266,19 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash 0.5.0", + "zeroize", +] + [[package]] name = "ark-bls12-381" version = "0.5.0" @@ -1864,6 +1887,27 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitfields" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "thiserror 2.0.18", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1948,6 +1992,16 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "blst" version = "0.3.16" @@ -2165,6 +2219,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -2251,6 +2314,16 @@ dependencies = [ "serde", ] +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "camino" version = "1.2.2" @@ -2327,6 +2400,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + [[package]] name = "castaway" version = "0.2.4" @@ -2360,7 +2442,16 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", ] [[package]] @@ -2477,6 +2568,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cmake" version = "0.1.58" @@ -2979,6 +3081,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + [[package]] name = "crc32fast" version = "1.5.0" @@ -3194,6 +3302,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3 0.10.9", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "darling" version = "0.20.11" @@ -3384,6 +3509,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "debug-helper" version = "0.3.13" @@ -3538,6 +3672,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "diff" version = "0.1.13" @@ -3711,6 +3854,22 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2", + "signature", + "zeroize", +] + [[package]] name = "dunce" version = "1.0.5" @@ -3743,6 +3902,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -3753,7 +3925,7 @@ dependencies = [ "digest 0.10.7", "elliptic-curve", "rfc6979", - "serdect", + "serdect 0.2.0", "signature", "spki", ] @@ -3811,6 +3983,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", + "base64ct", "crypto-bigint", "digest 0.10.7", "ff", @@ -3821,8 +3994,10 @@ dependencies = [ "pkcs8", "rand_core 0.6.4", "sec1", - "serdect", + "serde_json", + "serdect 0.2.0", "subtle", + "tap", "zeroize", ] @@ -4408,6 +4583,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec", "rand_core 0.6.4", "subtle", ] @@ -4498,6 +4674,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -4713,10 +4890,10 @@ dependencies = [ "eyre", "flate2", "fluentbase-genesis", + "fluentbase-release-verify", "fluentbase-revm", "fluentbase-runtime", "fluentbase-types", - "reqwest 0.12.28", "reth-chainspec", "reth-cli", "reth-cli-runner", @@ -4762,6 +4939,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "fluentbase-release-verify" +version = "1.3.3" +dependencies = [ + "alloy-genesis", + "flate2", + "hex", + "pgp", + "rand 0.8.6", + "reqwest 0.12.28", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "fluentbase-revm" version = "1.3.3" @@ -4811,15 +5005,17 @@ dependencies = [ "clap", "ethers", "flate2", + "fluentbase-release-verify", "fluentbase-sdk", "futures-util", "hex", - "reqwest 0.12.28", "reth-chainspec", "rpassword", "rwasm", "serde", "serde_json", + "sha2", + "tempfile", "tokio", ] @@ -4876,6 +5072,7 @@ dependencies = [ "serde_json", "syn 2.0.117", "syn-solidity", + "tempfile", "thiserror 2.0.18", "tracing", ] @@ -5927,6 +6124,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -6603,7 +6809,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "serdect", + "serdect 0.2.0", "sha2", "signature", ] @@ -7304,6 +7510,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "8.2.0" @@ -7374,6 +7589,23 @@ dependencies = [ "serde", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -7552,6 +7784,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -7940,6 +8184,32 @@ dependencies = [ "serde", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "page_size" version = "0.6.0" @@ -8019,6 +8289,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -8039,7 +8320,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", "hmac", - "password-hash", + "password-hash 0.4.2", "sha2", ] @@ -8107,6 +8388,75 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "pgp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64 0.22.1", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "camellia", + "cast5", + "cfb-mode", + "cipher", + "const-oid", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more 2.1.1", + "des", + "digest 0.10.7", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array 0.14.7", + "hex", + "hkdf", + "idea", + "k256", + "log", + "md-5", + "memchr", + "nom 8.0.0", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.6", + "replace_with", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2", + "sha3 0.10.9", + "signature", + "smallvec", + "snafu", + "subtle", + "twofish", + "x25519-dalek", + "zeroize", +] + [[package]] name = "pharos" version = "0.5.3" @@ -8234,6 +8584,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -9002,6 +9363,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + [[package]] name = "reqwest" version = "0.11.27" @@ -12073,6 +12440,26 @@ dependencies = [ "winapi 0.2.8", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "ruint" version = "1.18.0" @@ -12506,7 +12893,7 @@ dependencies = [ "der", "generic-array 0.14.7", "pkcs8", - "serdect", + "serdect 0.2.0", "subtle", "zeroize", ] @@ -12746,6 +13133,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -12768,6 +13165,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", + "zeroize", +] + [[package]] name = "sha2" version = "0.10.9" @@ -12951,6 +13359,27 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "snap" version = "1.1.1" @@ -14269,6 +14698,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + [[package]] name = "typeid" version = "1.0.3" @@ -15958,6 +16396,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" @@ -16125,6 +16575,12 @@ dependencies = [ "zstd 0.11.2+zstd.1.5.2", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index d2006be6d..88256235c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ fluentbase-crypto = { path = "./crates/crypto", default-features = false, versio fluentbase-evm = { path = "./crates/evm", default-features = false, version = "1.3.3" } fluentbase-genesis = { path = "./crates/genesis", default-features = false, version = "1.3.3" } fluentbase-node = { path = "./crates/node", default-features = false, version = "1.3.3" } +fluentbase-release-verify = { path = "./crates/release-verify", default-features = false, version = "1.3.3" } fluentbase-revm = { path = "./crates/revm", default-features = false, version = "1.3.3" } fluentbase-runtime = { path = "./crates/runtime", default-features = false, version = "1.3.3" } fluentbase-sdk = { path = "./crates/sdk", default-features = false, version = "1.3.3" } @@ -189,7 +190,7 @@ secp256k1 = { version = "0.31.0", default-features = false } sha2 = { version = "0.10.9", default-features = false } # revme -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } # reth reth-ethereum-cli = { git = "https://github.com/fluentlabs-xyz/reth.git", branch = "v2.2-patched" } @@ -255,6 +256,12 @@ num-bigint = { version = "0.4.6", default-features = false, features = [] } curve25519-dalek = { version = "4.1.3", default-features = false, features = ["alloc"] } flate2 = "1.1.9" parking_lot = "0.12" +# OpenPGP (rPGP), used to authenticate released genesis artifacts. +# `default-features = false` drops the bzip2 C dependency, which we never need for detached +# signature verification. +pgp = { version = "0.20.0", default-features = false } +# rPGP is built against rand 0.8, so tests that produce signatures need that exact major version. +rand_08 = { package = "rand", version = "0.8" } # dev-dependencies serde_derive = { version = "1.0", default-features = false } diff --git a/bins/runtime-upgrade/Cargo.toml b/bins/runtime-upgrade/Cargo.toml index 62026319d..672bc0908 100644 --- a/bins/runtime-upgrade/Cargo.toml +++ b/bins/runtime-upgrade/Cargo.toml @@ -18,6 +18,7 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } fluentbase-sdk = { workspace = true, features = ["std"] } rwasm = { workspace = true } alloy-genesis = { workspace = true } +fluentbase-release-verify = { workspace = true, features = ["reqwest"] } reth-chainspec = { workspace = true } clap = { workspace = true } flate2 = { workspace = true } @@ -27,9 +28,13 @@ hex = { workspace = true } alloy-sol-types = { workspace = true } # misc -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } futures-util = "0.3" rpassword = "0.3.0" # EVM / RPC / signing -ethers = { version = "2", default-features = false, features = ["abigen", "rustls", "ws"] } \ No newline at end of file +ethers = { version = "2", default-features = false, features = ["abigen", "rustls", "ws"] } + +[dev-dependencies] +fluentbase-release-verify = { workspace = true, features = ["test-support"] } +sha2 = { workspace = true, features = ["std"] } +tempfile = { workspace = true } diff --git a/bins/runtime-upgrade/main.rs b/bins/runtime-upgrade/main.rs index c962c9d22..ab011a513 100644 --- a/bins/runtime-upgrade/main.rs +++ b/bins/runtime-upgrade/main.rs @@ -1,3 +1,6 @@ +mod provenance; + +use crate::provenance::{load_release, ManifestBinding, ReleaseProvenance}; use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Parser, Subcommand}; use ethers::{ @@ -6,7 +9,6 @@ use ethers::{ signers::{LocalWallet, Signer}, types::{transaction::eip2718::TypedTransaction, NameOrAddress, TransactionRequest, U64}, }; -use flate2::read::GzDecoder; use fluentbase_sdk::{ bytes::BytesMut, codec::SolidityABI, crypto::crypto_keccak256, Address, Bytes, B256, PRECOMPILE_BIG_MODEXP, PRECOMPILE_BLAKE2F, PRECOMPILE_BLS12_381_G1_ADD, @@ -29,7 +31,7 @@ use serde::Serialize; use std::{ collections::HashMap, fs, - io::{Read, Write}, + io::Write, path::{Path, PathBuf}, sync::LazyLock, time::{SystemTime, UNIX_EPOCH}, @@ -60,6 +62,12 @@ struct CommonArgs { #[arg(long)] genesis: String, + /// Release channel of the genesis asset (e.g. `mainnet`). Omit for the default asset. + /// This selects which published artifact is authenticated, so it must match the network the + /// upgrade targets. Implied by --mainnet, which is why the two cannot be combined. + #[arg(long, conflicts_with = "mainnet")] + genesis_channel: Option, + /// Contract key name (e.g. PRECOMPILE_EVM_RUNTIME) from CONTRACTS_TO_UPGRADE. /// If omitted, upgrades all known contracts (with a prompt). #[arg(long)] @@ -77,11 +85,33 @@ struct CommonArgs { #[arg(long)] test: bool, - /// A custom RPC endpoint (overrides --local, --dev, --test) + /// Use mainnet RPC (https://rpc.fluent.xyz) and the `mainnet` genesis asset + #[arg(long)] + mainnet: bool, + + /// A custom RPC endpoint. Also select its network with --local/--dev/--test/--mainnet, or pass + /// --genesis-channel explicitly, so the authenticated genesis choice is never implicit. #[arg(long)] rpc: Option, } +impl CommonArgs { + /// Release channel of the genesis asset to authenticate. + /// + /// `--mainnet` implies the `mainnet` channel so the artifact cannot silently disagree with the + /// network being upgraded; clap rejects passing both. + fn genesis_channel(&self) -> Option<&str> { + match self.genesis_channel.as_deref() { + Some(channel) => Some(channel), + None if self.mainnet => Some(MAINNET_GENESIS_CHANNEL), + None => None, + } + } +} + +/// Genesis release channel for Fluent Mainnet artifacts. +const MAINNET_GENESIS_CHANNEL: &str = "mainnet"; + #[derive(Args, Debug)] struct TxArgs { /// Gas limit to use for upgrade transactions @@ -156,7 +186,9 @@ enum TransactionOutcome { } #[derive(Serialize)] -struct UpgradeResultManifest { +struct UpgradeResultManifest<'a> { + /// What the release artifact these payloads came from was proven to be. + provenance: &'a ReleaseProvenance, entries: Vec, } @@ -244,49 +276,27 @@ fn contracts_to_upgrade() -> HashMap<&'static str, Address> { ]) } -async fn download_genesis_file(genesis_version: &str) -> Result { - let output_file = format!("genesis-{}.json", genesis_version); - if Path::new(&output_file).exists() { - let json = fs::read_to_string(&output_file) - .with_context(|| format!("reading cached {}", output_file))?; - let result = serde_json::from_str::(json.as_str()) - .expect("failed to parse genesis json file"); - return Ok(result); - } - - let url = format!( - "https://github.com/fluentlabs-xyz/fluentbase/releases/download/{0}/genesis-{0}.json.gz", - genesis_version +/// Prints what the artifact's provenance was proven to be, before anything privileged happens. +fn report_provenance(provenance: &ReleaseProvenance) { + println!( + "Using {} from release {} (sha256 {})", + provenance.asset, provenance.tag, provenance.sha256 ); - - print!("Downloading genesis file from {}... ", url); - std::io::stdout().flush().ok(); - - let resp = reqwest::Client::builder() - .user_agent("fluent-chainspec/1.0") - .timeout(std::time::Duration::from_secs(60)) - .build()? - .get(url) - .send() - .await? - .error_for_status()?; - if !resp.status().is_success() { - bail!("HTTP error! {}", resp.status()); + match &provenance.manifest { + ManifestBinding::Verified => { + let commit = provenance.commit.as_deref().unwrap_or("unknown"); + println!(" provenance: signed release manifest verified (commit {commit})"); + } + ManifestBinding::Unavailable { reason } => { + println!(" provenance: detached signature verified"); + eprintln!( + " WARNING: release {} publishes no signed digest manifest ({reason}).\n\ + \x20 The artifact is bound by its detached signature only — there is no\n\ + \x20 independent binding of asset name and digest to this release.", + provenance.tag + ); + } } - let bytes = resp.bytes().await?; - - let mut decoder = GzDecoder::new(&bytes[..]); - let mut json = String::new(); - decoder - .read_to_string(&mut json) - .context("gunzip+read_to_string")?; - - fs::write(&output_file, json.as_bytes()).with_context(|| format!("writing {}", output_file))?; - println!("DONE"); - - let result = serde_json::from_str::(json.as_str()) - .expect("failed to parse genesis json file"); - Ok(result) } fn ask_for(prompt: &str) -> Result { @@ -305,25 +315,46 @@ fn ask_for_secret(prompt: &str) -> Result { } fn pick_rpc(args: &CommonArgs) -> Result { - if let Some(rpc) = &args.rpc { - return Ok(rpc.clone()); - } - let flags = [args.local, args.dev, args.test] + let flags = [args.local, args.dev, args.test, args.mainnet] .into_iter() .filter(|x| *x) .count(); + if let Some(rpc) = &args.rpc { + if flags > 1 { + bail!("You may select at most one of --local, --dev, --test, or --mainnet with --rpc"); + } + if flags == 0 && args.genesis_channel.is_none() { + bail!( + "--rpc requires an explicit network flag or --genesis-channel so the genesis \ + asset is not selected implicitly" + ); + } + return Ok(rpc.clone()); + } if flags != 1 { - bail!("You must specify exactly one of --local, --dev, or --test"); + bail!("You must specify exactly one of --local, --dev, --test, or --mainnet"); } Ok(if args.local { "http://localhost:8545".to_string() } else if args.dev { "https://rpc.devnet.fluent.xyz".to_string() - } else { + } else if args.test { "https://rpc.testnet.fluent.xyz".to_string() + } else { + "https://rpc.fluent.xyz".to_string() }) } +fn validate_genesis_chain_id(genesis_chain_id: u64, rpc_chain_id: u64) -> Result<()> { + if genesis_chain_id != rpc_chain_id { + bail!( + "authenticated genesis chain id {genesis_chain_id} does not match RPC chain id \ + {rpc_chain_id}; refusing to build or submit an upgrade" + ); + } + Ok(()) +} + fn strip_0x(s: &str) -> &str { s.strip_prefix("0x").unwrap_or(s) } @@ -350,12 +381,14 @@ fn contract_key_for(contracts: &HashMap<&'static str, Address>, contract: Addres const PLAN_UPGRADE_PREFIX: [u8; 4] = [0x50, 0xc9, 0xc6, 0x68]; const UPGRADE_TO_PLANNED_SIGNATURE: &[u8] = b"upgradeToPlanned(address,bytes)"; +#[allow(clippy::too_many_arguments)] fn write_safe_bundle( path: &Path, genesis_version: &str, genesis_hash: B256, chain_id: u64, updater: Address, + provenance: &ReleaseProvenance, planned_upgrades: &[PlannedUpgrade], ) -> Result<()> { if planned_upgrades.is_empty() { @@ -379,9 +412,16 @@ fn write_safe_bundle( .collect::>() .join("\n"); let description = format!( - "Fluent runtime upgrade plan bundle\nGenesis version: {}\nGenesis hash: {}\nUpdater: {}\nPlanned upgrades:\n{}", + "Fluent runtime upgrade plan bundle\nGenesis version: {}\nGenesis hash: {}\nGenesis artifact: {} (sha256 {})\nArtifact provenance: {}\nUpdater: {}\nPlanned upgrades:\n{}", genesis_version, genesis_hash, + provenance.asset, + provenance.sha256, + match &provenance.manifest { + ManifestBinding::Verified => "signed release manifest verified".to_string(), + ManifestBinding::Unavailable { .. } => + "detached signature only (release publishes no manifest)".to_string(), + }, address_hex(updater), metadata ); @@ -684,7 +724,17 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let common = cli.command.common(); - let genesis = download_genesis_file(&common.genesis).await?; + // Provenance first: nothing below this point may run against an unauthenticated artifact, and + // the operator wallet is not touched until it has passed. + let release = load_release( + &common.genesis, + common.genesis_channel(), + &provenance::cache_dir(), + ) + .await?; + report_provenance(&release.provenance); + let provenance = release.provenance; + let genesis = release.genesis; let genesis_header = make_genesis_header(&genesis, &FLUENT_HARDFORKS); let genesis_hash = genesis_header.hash_slow(); @@ -705,6 +755,7 @@ async fn main() -> Result<()> { .await .context("get_chainid")? .as_u64(); + validate_genesis_chain_id(genesis.config.chain_id, chain_id)?; match &cli.command { Command::PlanUpgrade(args) => { @@ -742,6 +793,7 @@ async fn main() -> Result<()> { genesis_hash, chain_id, args.updater, + &provenance, &planned_upgrades, )?; } @@ -750,6 +802,7 @@ async fn main() -> Result<()> { &args.tx, &provider, chain_id, + &provenance, &rwasm_module_by_address, upgrade_list, |contract, wasm_bytecode| { @@ -768,6 +821,7 @@ async fn main() -> Result<()> { &args.tx, &provider, chain_id, + &provenance, &rwasm_module_by_address, upgrade_list, encode_planned_upgrade_call, @@ -779,10 +833,12 @@ async fn main() -> Result<()> { Ok(()) } +#[allow(clippy::too_many_arguments)] async fn run_upgrade_transactions( tx_args: &TxArgs, provider: &Provider, chain_id: u64, + provenance: &ReleaseProvenance, rwasm_module_by_address: &HashMap, upgrade_list: Vec
, encode_call: impl Fn(Address, &[u8]) -> Vec, @@ -792,6 +848,7 @@ async fn run_upgrade_transactions( let wallet = wallet.with_chain_id(chain_id); let signer = SignerMiddleware::new(provider.clone(), wallet); let mut manifest = UpgradeResultManifest { + provenance, entries: Vec::new(), }; @@ -914,7 +971,7 @@ fn receipt_status(outcome: TransactionOutcome) -> Option { } } -fn print_result_manifest(manifest: &UpgradeResultManifest) -> Result<()> { +fn print_result_manifest(manifest: &UpgradeResultManifest<'_>) -> Result<()> { let json = serde_json::to_string(manifest).context("serializing result manifest")?; println!("RESULT_MANIFEST_JSON={}", json); Ok(()) @@ -924,6 +981,97 @@ fn print_result_manifest(manifest: &UpgradeResultManifest) -> Result<()> { mod tests { use super::*; + fn common_args(argv: &[&str]) -> CommonArgs { + #[derive(Parser, Debug)] + struct Harness { + #[command(flatten)] + common: CommonArgs, + } + let mut full = vec!["runtime-upgrade", "--genesis", "v1.3.2"]; + full.extend_from_slice(argv); + Harness::try_parse_from(full) + .unwrap_or_else(|err| panic!("parsing {argv:?}: {err}")) + .common + } + + #[test] + fn mainnet_flag_selects_the_mainnet_rpc_and_genesis_channel() { + let args = common_args(&["--mainnet"]); + assert_eq!(pick_rpc(&args).unwrap(), "https://rpc.fluent.xyz"); + assert_eq!(args.genesis_channel(), Some("mainnet")); + } + + #[test] + fn other_networks_keep_the_default_genesis_channel() { + for (flag, rpc) in [ + ("--local", "http://localhost:8545"), + ("--dev", "https://rpc.devnet.fluent.xyz"), + ("--test", "https://rpc.testnet.fluent.xyz"), + ] { + let args = common_args(&[flag]); + assert_eq!(pick_rpc(&args).unwrap(), rpc, "{flag}"); + assert_eq!(args.genesis_channel(), None, "{flag}"); + } + } + + #[test] + fn genesis_channel_can_be_set_explicitly_without_mainnet() { + // A custom mainnet RPC still needs the mainnet artifact, so the two stay separable. + let args = common_args(&[ + "--rpc", + "https://internal.example", + "--genesis-channel", + "mainnet", + ]); + assert_eq!(pick_rpc(&args).unwrap(), "https://internal.example"); + assert_eq!(args.genesis_channel(), Some("mainnet")); + } + + #[test] + fn custom_rpc_requires_an_explicit_genesis_selection() { + let err = pick_rpc(&common_args(&["--rpc", "https://internal.example"])) + .expect_err("a custom RPC must not silently select the default genesis asset"); + assert!(err.to_string().contains("genesis"), "{err:#}"); + + let args = common_args(&["--rpc", "https://internal.example", "--dev"]); + assert_eq!(pick_rpc(&args).unwrap(), "https://internal.example"); + assert_eq!(args.genesis_channel(), None); + } + + #[test] + fn rpc_chain_must_match_authenticated_genesis() { + validate_genesis_chain_id(25_363, 25_363).unwrap(); + let err = validate_genesis_chain_id(25_363, 20_993) + .expect_err("a mainnet genesis must not target a devnet RPC"); + assert!(err.to_string().contains("does not match"), "{err:#}"); + } + + #[test] + fn mainnet_cannot_be_combined_with_an_explicit_genesis_channel() { + // Letting these disagree would authenticate one network's artifact for another's upgrade. + #[derive(Parser, Debug)] + struct Harness { + #[command(flatten)] + common: CommonArgs, + } + Harness::try_parse_from([ + "runtime-upgrade", + "--genesis", + "v1.3.2", + "--mainnet", + "--genesis-channel", + "devnet", + ]) + .expect_err("conflicting channel selection must be rejected"); + } + + #[test] + fn exactly_one_network_flag_is_required() { + pick_rpc(&common_args(&[])).expect_err("no network flag must be rejected"); + pick_rpc(&common_args(&["--mainnet", "--dev"])) + .expect_err("two network flags must be rejected"); + } + #[test] fn preflight_fails_when_selected_contract_is_missing() { let modules = HashMap::new(); diff --git a/bins/runtime-upgrade/provenance.rs b/bins/runtime-upgrade/provenance.rs new file mode 100644 index 000000000..87ca76c14 --- /dev/null +++ b/bins/runtime-upgrade/provenance.rs @@ -0,0 +1,486 @@ +//! Fail-closed loading of the release artifact the upgrade payloads are cut from. +//! +//! Everything this tool signs with the operator wallet is derived from a genesis `.json.gz` +//! published on GitHub releases, so that artifact is the real input to a privileged action. It is +//! treated as untrusted until it has been authenticated against the release key pinned in +//! [`fluentbase_release_verify`]: +//! +//! * the detached OpenPGP signature is required for both cached and downloaded artifacts, and is +//! checked over the exact bytes that are later parsed; +//! * when the release publishes a signed digest manifest, it is verified too and must list this +//! exact asset, at this exact digest, for this exact release — that is what binds the artifact to +//! a network and rules out replaying another release's manifest; +//! * releases predating the manifest (pre-`v1.3.x`) are accepted on the detached signature alone, +//! and the run says so loudly and records it in the result manifest; +//! * any failure aborts before the wallet is loaded or a transaction is built. + +use alloy_genesis::Genesis; +use anyhow::{anyhow, Context, Result}; +use fluentbase_release_verify::{ + bounded_http_fetch, load_verified, parse_genesis_gz, FetchError, Fetcher, ReleaseAsset, + ReleaseKey, ReleaseManifest, VerifyError, +}; +use serde::Serialize; +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; + +/// Timeout for a single artifact download. +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300); + +/// How the release's signed manifest was (or was not) able to vouch for the artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ManifestBinding { + /// The manifest verified and lists this asset at this digest for this release. + Verified, + /// This release publishes no manifest; only the detached signature bound the artifact. + Unavailable { reason: String }, +} + +/// What the artifact's provenance was proven to be. Recorded in the run's result manifest so an +/// upgrade can be audited after the fact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReleaseProvenance { + /// Release tag the artifact came from. + pub tag: String, + /// Published asset name. + pub asset: String, + /// SHA-256 the artifact authenticated at. + pub sha256: String, + /// Source commit, when the manifest supplied one. + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + pub manifest: ManifestBinding, +} + +/// An authenticated release artifact, parsed. +#[derive(Debug)] +pub struct VerifiedRelease { + pub genesis: Genesis, + pub provenance: ReleaseProvenance, +} + +/// Loads and authenticates the genesis artifact of release `tag`. +/// +/// Runs the blocking verification off the async runtime; the CLI has no other work to overlap with +/// it, and a blocking HTTP client keeps the trust-critical path free of task scheduling. +pub async fn load_release( + tag: &str, + channel: Option<&str>, + cache_dir: &Path, +) -> Result { + let tag = tag.to_owned(); + let channel = channel.map(str::to_owned); + let cache_dir = cache_dir.to_path_buf(); + + tokio::task::spawn_blocking(move || { + let key = ReleaseKey::fluent().context("release key is unusable")?; + load_release_blocking(&tag, channel.as_deref(), &cache_dir, &key, &blocking_fetch) + }) + .await + .context("release verification task panicked")? +} + +/// The verification flow, with the HTTP client and trust root injected so tests can drive it. +pub(crate) fn load_release_blocking( + tag: &str, + channel: Option<&str>, + cache_dir: &Path, + key: &ReleaseKey, + fetch: &Fetcher<'_>, +) -> Result { + let asset = ReleaseAsset::genesis(tag.to_owned(), channel) + .context("invalid genesis release tag or channel")?; + + let artifact = load_verified(Some(cache_dir), &asset, key, fetch) + .with_context(|| format!("refusing to use {}: it is not authentic", asset.name()))?; + + let (manifest, binding) = load_manifest(tag, cache_dir, key, fetch)?; + if let Some(manifest) = &manifest { + manifest + .check(tag, asset.name(), &artifact.sha256) + .with_context(|| { + format!( + "refusing to use {}: the release manifest does not vouch for it", + asset.name() + ) + })?; + } + + let genesis = parse_genesis_gz(&artifact).context("parsing authenticated genesis")?; + + Ok(VerifiedRelease { + genesis, + provenance: ReleaseProvenance { + tag: tag.to_owned(), + asset: asset.name().to_owned(), + sha256: artifact.sha256_hex(), + commit: manifest.as_ref().map(|m| m.commit().to_owned()), + manifest: binding, + }, + }) +} + +/// Loads the release's signed digest manifest, if it publishes one. +/// +/// A manifest that returns `404 Not Found` is treated as "this release has none" — the detached +/// signature has already bound the artifact, and older releases genuinely predate manifests. Any +/// other transport, authentication, or parse failure is fatal rather than a verification downgrade. +fn load_manifest( + tag: &str, + cache_dir: &Path, + key: &ReleaseKey, + fetch: &Fetcher<'_>, +) -> Result<(Option, ManifestBinding)> { + let asset = ReleaseAsset::manifest(tag.to_owned()).context("invalid release tag")?; + + let artifact = match load_verified(Some(cache_dir), &asset, key, fetch) { + Ok(artifact) => artifact, + Err(VerifyError::Fetch { url, source }) if source.is_not_found() && url == asset.url() => { + return Ok(( + None, + ManifestBinding::Unavailable { + reason: source.to_string(), + }, + )) + } + Err(err) => { + return Err(anyhow!(err)).with_context(|| { + format!( + "refusing to continue: {} did not authenticate", + asset.name() + ) + }) + } + }; + + let manifest = ReleaseManifest::parse(&artifact.bytes) + .map_err(|err| anyhow!(err)) + .with_context(|| format!("parsing {}", asset.name()))?; + + Ok((Some(manifest), ManifestBinding::Verified)) +} + +/// Downloads `url` into memory, refusing responses larger than `max_bytes`. +fn blocking_fetch(url: &str, max_bytes: usize) -> Result, FetchError> { + bounded_http_fetch( + "fluent-runtime-upgrade/1.0", + DOWNLOAD_TIMEOUT, + url, + max_bytes, + ) +} + +/// Where authenticated artifacts are cached: alongside the working directory, as before. +pub fn cache_dir() -> PathBuf { + PathBuf::from(".") +} + +#[cfg(test)] +mod tests { + use super::*; + use fluentbase_release_verify::test_support::{ + gzip, offline, plant_cache, FakeRelease, TestKey, + }; + use sha2::Digest as _; + + const TAG: &str = "v1.3.2"; + + fn genesis_gz() -> Vec { + gzip( + br#"{"config":{"chainId":20993},"alloc":{},"gasLimit":"0x1c9c380","difficulty":"0x0"}"#, + ) + } + + fn substituted_gz() -> Vec { + gzip(br#"{"config":{"chainId":31337},"alloc":{},"gasLimit":"0x1","difficulty":"0x0"}"#) + } + + fn manifest_for(tag: &str, entries: &[(&str, &[u8])]) -> Vec { + let mut out = format!("version={tag}\ncommit=deadbeef\n\n[compressed]\n"); + for (name, bytes) in entries { + out.push_str(&format!( + "{} ./artifacts/{name}\n", + hex::encode(<[u8; 32]>::from(sha2::Sha256::digest(bytes))) + )); + } + out.into_bytes() + } + + /// A release that publishes a genesis artifact and a matching signed manifest. + fn full_release( + release: &TestKey, + tag: &str, + channel: Option<&str>, + gz: &[u8], + ) -> (ReleaseAsset, FakeRelease) { + let asset = + ReleaseAsset::genesis(tag.to_owned(), channel).expect("valid test genesis asset"); + let manifest_asset = + ReleaseAsset::manifest(tag.to_owned()).expect("valid test manifest asset"); + let manifest = manifest_for(tag, &[(asset.name(), gz)]); + let feed = FakeRelease::new() + .publish(&asset, gz.to_vec(), release.sign(gz)) + .publish(&manifest_asset, manifest.clone(), release.sign(&manifest)); + (asset, feed) + } + + #[test] + fn valid_release_is_accepted_and_manifest_bound() { + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let (asset, feed) = full_release(&release, TAG, None, &gz); + + let loaded = load_release_blocking(TAG, None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect("a valid release must be accepted"); + + assert_eq!(loaded.genesis.config.chain_id, 20993); + assert_eq!(loaded.provenance.manifest, ManifestBinding::Verified); + assert_eq!(loaded.provenance.asset, asset.name()); + assert_eq!(loaded.provenance.commit.as_deref(), Some("deadbeef")); + assert_eq!( + loaded.provenance.sha256, + hex::encode(<[u8; 32]>::from(sha2::Sha256::digest(&gz))) + ); + } + + #[test] + fn same_name_cache_substitution_is_rejected() { + // The reported hole: drop a same-name file next to the tool and let the next run use it. + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let evil = substituted_gz(); + let asset = ReleaseAsset::genesis(TAG.to_owned(), None).unwrap(); + plant_cache(dir.path(), &asset, &evil, &attacker.sign(&evil)); + + load_release_blocking(TAG, None, dir.path(), &release.key(), &offline) + .expect_err("a substituted cache must never be used"); + assert!( + !dir.path().join(asset.name()).exists(), + "the rejected cache entry must be gone" + ); + } + + #[test] + fn substituted_cache_is_replaced_by_the_authentic_release() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let (asset, feed) = full_release(&release, TAG, None, &gz); + + let evil = substituted_gz(); + plant_cache(dir.path(), &asset, &evil, &attacker.sign(&evil)); + + let loaded = load_release_blocking(TAG, None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect("must fall back to the release"); + assert_eq!(loaded.genesis.config.chain_id, 20993); + assert_eq!(std::fs::read(dir.path().join(asset.name())).unwrap(), gz); + } + + #[test] + fn modified_gzip_is_rejected() { + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let (asset, _) = full_release(&release, TAG, None, &gz); + + // A single flipped byte in an otherwise genuine artifact. + let mut modified = gz.clone(); + *modified.last_mut().unwrap() ^= 0x01; + let manifest_asset = ReleaseAsset::manifest(TAG.to_owned()).unwrap(); + let manifest = manifest_for(TAG, &[(asset.name(), &gz)]); + let feed = FakeRelease::new() + .publish(&asset, modified, release.sign(&gz)) + .publish(&manifest_asset, manifest.clone(), release.sign(&manifest)); + + load_release_blocking(TAG, None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect_err("a modified gzip must be rejected"); + } + + #[test] + fn signature_failure_is_rejected() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let (asset, _) = full_release(&release, TAG, None, &gz); + let feed = FakeRelease::new().publish(&asset, gz.clone(), attacker.sign(&gz)); + + load_release_blocking(TAG, None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect_err("an artifact signed by another key must be rejected"); + } + + #[test] + fn manifest_from_another_release_is_rejected() { + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let asset = ReleaseAsset::genesis(TAG.to_owned(), None).unwrap(); + let manifest_asset = ReleaseAsset::manifest(TAG.to_owned()).unwrap(); + + // Validly signed, but cut from a different release. + let manifest = manifest_for("v1.3.1", &[(asset.name(), &gz)]); + let feed = FakeRelease::new() + .publish(&asset, gz.clone(), release.sign(&gz)) + .publish(&manifest_asset, manifest.clone(), release.sign(&manifest)); + + let err = load_release_blocking(TAG, None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect_err("a manifest from another release must be rejected"); + assert!( + format!("{err:#}").contains("does not vouch"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn manifest_for_another_network_is_rejected() { + // The mainnet and devnet artifacts of one release differ only by asset name, so asking for + // the mainnet asset against a manifest that only lists the devnet one must fail. + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let devnet = ReleaseAsset::genesis(TAG.to_owned(), None).unwrap(); + let mainnet = ReleaseAsset::genesis(TAG.to_owned(), Some("mainnet")).unwrap(); + let manifest_asset = ReleaseAsset::manifest(TAG.to_owned()).unwrap(); + + let manifest = manifest_for(TAG, &[(devnet.name(), &gz)]); + let feed = FakeRelease::new() + .publish(&mainnet, gz.clone(), release.sign(&gz)) + .publish(&manifest_asset, manifest.clone(), release.sign(&manifest)); + + let err = load_release_blocking( + TAG, + Some("mainnet"), + dir.path(), + &release.key(), + &|url, max| feed.fetch(url, max), + ) + .expect_err("a manifest that omits this network's asset must be rejected"); + assert!( + format!("{err:#}").contains("does not vouch"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn tampered_manifest_is_fatal_not_ignored() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let asset = ReleaseAsset::genesis(TAG.to_owned(), None).unwrap(); + let manifest_asset = ReleaseAsset::manifest(TAG.to_owned()).unwrap(); + let manifest = manifest_for(TAG, &[(asset.name(), &gz)]); + + let feed = FakeRelease::new() + .publish(&asset, gz.clone(), release.sign(&gz)) + .publish(&manifest_asset, manifest.clone(), attacker.sign(&manifest)); + + let err = load_release_blocking(TAG, None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect_err("a manifest that fails to authenticate must abort the run"); + assert!( + format!("{err:#}").contains("did not authenticate"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn unsigned_manifest_is_fatal_not_treated_as_absent() { + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let asset = ReleaseAsset::genesis(TAG.to_owned(), None).unwrap(); + let manifest_asset = ReleaseAsset::manifest(TAG.to_owned()).unwrap(); + let manifest = manifest_for(TAG, &[(asset.name(), &gz)]); + let feed = FakeRelease::new() + .publish(&asset, gz.clone(), release.sign(&gz)) + // Publish the manifest body without its detached signature. + .publish(&manifest_asset, manifest.clone(), Vec::new()); + + let err = load_release_blocking(TAG, None, dir.path(), &release.key(), &|url, max| { + if url == manifest_asset.signature_url() { + Err(FetchError::not_found("manifest signature returned 404")) + } else { + feed.fetch(url, max) + } + }) + .expect_err("a manifest without its detached signature must abort the run"); + assert!( + format!("{err:#}").contains("did not authenticate"), + "{err:#}" + ); + } + + #[test] + fn release_without_a_manifest_is_accepted_and_flagged() { + // Pre-v1.3.x releases publish no manifest; the detached signature still has to hold. + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let asset = ReleaseAsset::genesis("v0.5.7".to_owned(), None).unwrap(); + let feed = FakeRelease::new().publish(&asset, gz.clone(), release.sign(&gz)); + + let loaded = + load_release_blocking("v0.5.7", None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect("a signed artifact from a pre-manifest release must still load"); + assert!(matches!( + loaded.provenance.manifest, + ManifestBinding::Unavailable { .. } + )); + assert_eq!(loaded.provenance.commit, None); + } + + #[test] + fn manifest_transport_failure_is_fatal() { + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let asset = ReleaseAsset::genesis("v0.5.7".to_owned(), None).unwrap(); + let feed = FakeRelease::new().publish(&asset, gz.clone(), release.sign(&gz)); + + let err = load_release_blocking("v0.5.7", None, dir.path(), &release.key(), &|url, max| { + if url.contains("genesis-manifest-") { + Err(FetchError::new("network timeout")) + } else { + feed.fetch(url, max) + } + }) + .expect_err("a timeout must not masquerade as a release without a manifest"); + + assert!(format!("{err:#}").contains("network timeout"), "{err:#}"); + } + + #[test] + fn unmanifested_release_still_requires_a_valid_signature() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let gz = genesis_gz(); + let asset = ReleaseAsset::genesis("v0.5.7".to_owned(), None).unwrap(); + let feed = FakeRelease::new().publish(&asset, gz.clone(), attacker.sign(&gz)); + + load_release_blocking("v0.5.7", None, dir.path(), &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect_err("a missing manifest must not weaken the signature requirement"); + } +} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index b30380a63..5f4436c03 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -2092,6 +2092,7 @@ dependencies = [ name = "fluentbase-contracts-runtime-upgrade" version = "0.1.0" dependencies = [ + "alloy-sol-types", "fluentbase-build", "fluentbase-sdk", "fluentbase-testing", diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index dc6266f87..5b3d96305 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -29,6 +29,7 @@ revm-precompile = { git = "https://github.com/fluentlabs-xyz/revm-rwasm.git", br #solana-program-option = { git = "https://github.com/fluentlabs-xyz/agave", branch = "feat/svm", default-features = false } #solana-bincode = { git = "https://github.com/fluentlabs-xyz/agave", branch = "feat/svm", default-features = false } # misc +alloy-sol-types = { version = "1.5.0" } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } ecdsa = { git = "https://github.com/rwasm-patches/signatures.git", default-features = false, features = ["hazmat", "verifying"] } k256 = { version = "0.13.4", default-features = false } diff --git a/contracts/eip7951/src/lib.rs b/contracts/eip7951/src/lib.rs index 756d5b62c..841f74367 100644 --- a/contracts/eip7951/src/lib.rs +++ b/contracts/eip7951/src/lib.rs @@ -5,12 +5,12 @@ extern crate fluentbase_sdk; use fluentbase_sdk::{system_entrypoint, ExitCode, SystemAPI}; use revm_precompile::{ - secp256r1::{p256_verify, P256VERIFY_BASE_GAS_FEE}, + secp256r1::{p256_verify_osaka, P256VERIFY_BASE_GAS_FEE_OSAKA}, PrecompileHalt, }; /// Main entry point for the secp256r1 wrapper contract. -/// This contract wraps the secp256r1 precompile (EIP-7212) which verifies ECDSA signatures +/// This contract wraps the secp256r1 precompile (EIP-7951) which verifies ECDSA signatures /// using the secp256r1 (P-256) elliptic curve. /// /// Input format: @@ -19,12 +19,12 @@ use revm_precompile::{ /// | 32 | 32 | 32 | 32 | 32 | /// /// Output: -/// - Returns a single byte with value 1 if the signature is valid +/// - Returns a 32-byte value ending in 1 if the signature is valid /// - Returns an empty byte array if the signature is invalid pub fn main_entry(sdk: &mut SDK) -> Result<(), ExitCode> { let input = sdk.bytes_input(); - sdk.sync_evm_gas(P256VERIFY_BASE_GAS_FEE)?; - let result = p256_verify(input.as_ref(), u64::MAX).map_err(|err| match err { + sdk.sync_evm_gas(P256VERIFY_BASE_GAS_FEE_OSAKA)?; + let result = p256_verify_osaka(input.as_ref(), u64::MAX).map_err(|err| match err { PrecompileHalt::OutOfGas => ExitCode::OutOfFuel, _ => ExitCode::PrecompileError, })?; @@ -66,7 +66,7 @@ mod tests { exec_evm_precompile( &hex!("4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4da73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d604aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff37618b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e"), &B256::with_last_byte(1)[..], - 3450, + P256VERIFY_BASE_GAS_FEE_OSAKA, ); } #[test] @@ -75,17 +75,26 @@ mod tests { exec_evm_precompile( &hex!("e775723953ead4a90411a02908fd1a629db584bc600664c609061f221ef6bf7cbe0aca61c884167420c8d16b6a22b5952ab46586c0fdba026cd0bf32258c500434091e9ec6503491ed0a820c38ad82c672d88c9fe8e681accdf7e26dbd2d7958b6114ed3a5b9bf7255eda3077b2c63ad476d481d979699a5d22bd030077dab338bea1ad18733e7d410649a8b09c6429ea065c9d66aaf6a8e793b0567eadd942d"), &B256::with_last_byte(1)[..], - 3450, + P256VERIFY_BASE_GAS_FEE_OSAKA, ); } #[test] fn test_invalid_signature() { - // Modified test vector with invalid signature + // Modified message hash with an otherwise well-formed signature + exec_evm_precompile( + &hex!("4dee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4da73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d604aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff37618b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e"), + &hex!(""), + P256VERIFY_BASE_GAS_FEE_OSAKA, + ); + } + + #[test] + fn test_malformed_signature_scalar() { exec_evm_precompile( &hex!("4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff4aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff37618b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e"), &hex!(""), - 3450, + P256VERIFY_BASE_GAS_FEE_OSAKA, ); } @@ -95,14 +104,18 @@ mod tests { exec_evm_precompile( &hex!("4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4da73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), &hex!(""), - 3450, + P256VERIFY_BASE_GAS_FEE_OSAKA, ); } #[test] fn test_incorrect_input_length() { // Test with too short input - exec_evm_precompile(&hex!("4cee90eb86eaa050036147a12d49004b6a"), &hex!(""), 3450); + exec_evm_precompile( + &hex!("4cee90eb86eaa050036147a12d49004b6a"), + &hex!(""), + P256VERIFY_BASE_GAS_FEE_OSAKA, + ); } #[test] diff --git a/contracts/evm/src/lib.rs b/contracts/evm/src/lib.rs index 6b4971d0f..f44c74222 100644 --- a/contracts/evm/src/lib.rs +++ b/contracts/evm/src/lib.rs @@ -1,3 +1,13 @@ +//! The EVM runtime contract: executes EVM bytecode for every account that delegates to +//! `PRECOMPILE_EVM_RUNTIME`. +//! +//! This contract is the unit of EVM versioning on Fluent. It is ordinary genesis rWASM code, and +//! `contracts/runtime-upgrade` can replace it on a live chain, so EVM semantics are upgraded +//! forklessly: ship a new runtime and every delegating account follows it immediately. Because +//! of that, the interpreter always runs at a single pinned hardfork (Osaka) instead of tracking +//! the chain's active fork — the deployed runtime version is the fork boundary. See the +//! `fluentbase_evm::evm` module docs for the full rationale before changing this. + #![cfg_attr(target_arch = "wasm32", no_std, no_main)] extern crate alloc; diff --git a/contracts/fee-manager/src/lib.rs b/contracts/fee-manager/src/lib.rs index d62691a9c..13c0ccec2 100644 --- a/contracts/fee-manager/src/lib.rs +++ b/contracts/fee-manager/src/lib.rs @@ -32,13 +32,21 @@ pub trait FeeManagerTr { /// Withdraw balance from the contract fn withdraw(&mut self, recipient: Address); - /// Change contract owner + /// Change contract owner. + /// + /// `new_owner` must be non-zero: an empty owner slot means "genesis bootstrap authority", so a + /// zero transfer would hand control back to the launch key instead of clearing it. Once + /// ownership has moved off the bootstrap key, only an explicit transfer naming + /// `DEFAULT_FEE_MANAGER_AUTH` can bring it back. fn change_owner(&mut self, new_owner: Address); /// Get the current contract owner fn owner(&mut self) -> Address; - /// Renounce ownership (change an owner to system contract address) + /// Renounce ownership (change an owner to system contract address). + /// + /// This is the only way to give up ownership, and it is fork-only by intent: `SYSTEM_ADDRESS` + /// is unreachable as a caller, unlike the zero address. fn renounce_ownership(&mut self); } @@ -60,6 +68,13 @@ impl FeeManagerTr for App { fn change_owner(&mut self, new_owner: Address) { _ = self.only_owner(); + // Zero is not a neutral value here: `owner()` and `only_owner()` map an empty slot back to + // the genesis bootstrap key, so storing zero would silently reactivate a retired authority + // after governance has already handed control over. `renounceOwnership` stays the explicit + // fork-only transition. + if new_owner == Address::ZERO { + panic!("fee-manager: can't set owner to zero address"); + } self.owner_accessor().set(&mut self.sdk, new_owner); OwnerChanged { new_owner }.emit(&mut self.sdk).unwrap(); } diff --git a/contracts/runtime-upgrade/Cargo.toml b/contracts/runtime-upgrade/Cargo.toml index b60428f1c..7d8ef9b04 100644 --- a/contracts/runtime-upgrade/Cargo.toml +++ b/contracts/runtime-upgrade/Cargo.toml @@ -8,6 +8,8 @@ fluentbase-sdk = { workspace = true } [dev-dependencies] fluentbase-testing = { workspace = true } +# Independent Solidity ABI decoder used to validate emitted event log data. +alloy-sol-types = { workspace = true } [build-dependencies] fluentbase-build = { workspace = true } diff --git a/contracts/runtime-upgrade/README.md b/contracts/runtime-upgrade/README.md index 9334af29a..829cb4179 100644 --- a/contracts/runtime-upgrade/README.md +++ b/contracts/runtime-upgrade/README.md @@ -46,10 +46,25 @@ as `upgradeTo`, removes the consumed pair, and emits `RuntimeUpgraded`. Removing the pair prevents replaying the same planned target/hash entry after it has been installed. +### `changeOwner(address newOwner)` / `renounceOwnership()` + +Owner-only ownership transitions. `renounceOwnership` moves ownership to `SYSTEM_ADDRESS` so the +runtime becomes fork-maintained. + +Both cancel the pending plan atomically before the owner slot moves: the stored `upgrador`, the +release metadata, and every remaining target/hash pair are cleared, and `UpgradePlanCancelled( +genesisHash, upgrador, targets, wasmHashes)` is emitted describing exactly what was revoked. The +event is skipped when no plan exists. If a transition reverts (zero address, non-owner caller), the +plan is left untouched along with the rest of the state. + ## Trust Model - `owner` can perform direct upgrades, recompile existing targets, and replace the current plan. - `upgrador` can execute only owner-approved target/hash pairs from the current plan. +- Delegated upgrade authority never outlives the owner that granted it. A plan is scoped to its + creating owner, so ownership rotation is sufficient for compromise response and renunciation + cannot leave a delegated upgrader live. A new owner must call `planUpgrade` again to re-authorize + a delegate; it never inherits stale targets, hashes, or metadata. - Host-side syscall enforcement remains the final boundary: `SYSCALL_ID_UPGRADE_WASM_RUNTIME` must only be reachable through the runtime-upgrade precompile execution path. diff --git a/contracts/runtime-upgrade/src/lib.rs b/contracts/runtime-upgrade/src/lib.rs index 2e5d361dd..7763544f4 100644 --- a/contracts/runtime-upgrade/src/lib.rs +++ b/contracts/runtime-upgrade/src/lib.rs @@ -45,6 +45,15 @@ struct UpgradePlanned { updater: Address, } +#[derive(Event)] +struct UpgradePlanCancelled { + #[indexed] + genesis_hash: B256, + updater: Address, + target_addresses: Vec
, + wasm_code_hashes: Vec, +} + #[derive(Event)] struct OwnerChanged { new_owner: Address, @@ -87,6 +96,9 @@ trait RuntimeUpgradeTr { /// /// The target address is part of the authorization boundary: approving only a WASM hash would /// let the delegated upgrader install approved bytecode at the wrong system address. + /// + /// The plan is scoped to the owner that created it: any ownership transition cancels it (see + /// [`RuntimeUpgradeTr::change_owner`]), so a new owner must re-authorize the delegation. fn plan_upgrade( &mut self, genesis_hash: B256, @@ -99,13 +111,21 @@ trait RuntimeUpgradeTr { /// Upgrade WASM runtime smart contract using a previously planned target/hash pair. fn upgrade_to_planned(&mut self, target_address: Address, wasm_bytecode: Bytes); - /// Change contract owner + /// Change contract owner. + /// + /// Lifecycle invariant: delegated upgrade authority never outlives the owner that granted it. + /// Any pending plan (updater, genesis metadata and every remaining target/hash pair) is + /// cancelled atomically, so the previous updater cannot install anything under the new owner. fn change_owner(&mut self, new_owner: Address); /// Get the current contract owner fn owner(&mut self) -> Address; - /// Renounce ownership (change an owner to system contract address) + /// Renounce ownership (change an owner to system contract address). + /// + /// Cancels any pending upgrade plan for the same reason as + /// [`RuntimeUpgradeTr::change_owner`]: renunciation hands the runtime over to forks, so no + /// delegated updater may stay live afterwards. fn renounce_ownership(&mut self); } @@ -288,6 +308,8 @@ impl RuntimeUpgradeTr for App { if new_owner == Address::ZERO { panic!("runtime-upgrade: can't set owner to zero address"); } + // Revoke before handing over: the outgoing owner's delegate must not survive the rotation. + self.cancel_planned_upgrade(); self.owner_accessor().set(&mut self.sdk, new_owner); OwnerChanged { new_owner }.emit(&mut self.sdk).unwrap(); } @@ -304,6 +326,8 @@ impl RuntimeUpgradeTr for App { #[function_id("renounceOwnership()")] fn renounce_ownership(&mut self) { _ = self.only_owner(); + // Renunciation is fork-only by intent, so no delegated updater may stay live. + self.cancel_planned_upgrade(); // We set to `SYSTEM_ADDRESS` to make a system fully maintained by forks (if it's required) self.owner_accessor().set(&mut self.sdk, SYSTEM_ADDRESS); OwnerChanged { @@ -315,6 +339,32 @@ impl RuntimeUpgradeTr for App { } impl App { + /// Compile raw WASM and install the resulting rWasm at `target_address`. + /// + /// Deliberately unbounded: neither `WASM_MAX_CODE_SIZE` nor `RWASM_MAX_CODE_SIZE` is applied + /// here, and that is not an oversight. Those caps exist to bound what an *untrusted* deployer + /// can push into state and how much compilation an untrusted caller can force through `CREATE`. + /// Every path into this function is already gated on upgrade authority — the owner directly, or + /// a delegated updater presenting a target/hash pair the owner approved in advance — so the + /// bytecode and the compilation work both come from a trusted source and the caps protect + /// against nothing that authority does not already imply. + /// + /// Applying them would instead cost us something real: system runtimes are the largest binaries + /// on the chain and already sit near the deploy-time WASM cap, so a genesis contract that + /// outgrows it must stay upgradeable without a fork to raise a constant. That is precisely what + /// this contract exists to make possible. + /// + /// Note the deliberate asymmetry with [`App::install_evm`] below, which *does* enforce + /// `EVM_MAX_CODE_SIZE`: EIP-170 is consensus-visible for EVM accounts, so an oversized EVM + /// runtime would be observably out of spec rather than merely large. + /// + /// The one caveat worth knowing: `EXT_CODE_COPY_MAX_COPY_SIZE` bounds a single `CODE_COPY` + /// request, and [`RuntimeUpgradeTr::recompile`] reads a target back in one full-length copy. A + /// target installed above that bound therefore cannot be recompiled through this contract and + /// would need a fresh `upgradeTo` carrying the WASM again. + /// + /// Audit note: raised and closed as intended behaviour (FLU-1075). Please do not "fix" this by + /// adding a size check without revisiting the upgrade-authority argument above. fn compile_and_install(&mut self, target_address: Address, wasm_bytecode: Bytes) -> B256 { if !wasm_bytecode.starts_with(&WASM_MAGIC_BYTES) { panic!("runtime-upgrade: malformed wasm bytecode"); @@ -339,11 +389,14 @@ impl App { panic!("runtime-upgrade: failed to upgrade"); } - let Ok(code_hash) = self.sdk.code_hash(&target_address).ok() else { - panic!("runtime-upgrade: can't obtain code hash"); - }; - - code_hash + // Hash the exact bytes we just installed instead of reading the hash back through + // `code_hash`. That syscall is the EVM-facing `EXTCODEHASH`, which deliberately reports zero + // for canonical precompile addresses (0x01..=0x11) even though Fluent keeps real rWasm code + // there — so reading it back would emit a zero artifact hash for exactly the consensus- + // critical upgrades that most need an auditable one. For every other target the two agree: + // `SYSCALL_ID_UPGRADE_WASM_RUNTIME` stores `Bytecode::Rwasm`, whose account code hash is + // keccak256 over this same serialized module. + crypto_keccak256(&rwasm_bytecode) } fn install_evm(&mut self, target_address: Address, evm_bytecode: Bytes) -> B256 { @@ -367,11 +420,50 @@ impl App { panic!("runtime-upgrade: failed to upgrade evm contract"); } - let Ok(code_hash) = self.sdk.code_hash(&target_address).ok() else { - panic!("runtime-upgrade: can't obtain code hash"); - }; + // Same reasoning as `compile_and_install`: hash the installed bytes directly so canonical + // precompile targets get a real artifact hash. `SYSCALL_ID_UPGRADE_EVM_RUNTIME` wraps this + // bytecode in `EthereumMetadata::new_analyzed`, which records keccak256 over these same + // bytes as the account's EVM code hash, so unmasked targets keep the value they had before. + crypto_keccak256(evm_bytecode.as_ref()) + } - code_hash + /// Drop the whole plan: the delegated updater, the genesis metadata it was scoped to, and + /// every remaining target/hash pair. A no-op when nothing is planned, so ownership + /// transitions stay cheap and emit no misleading cancellation event. + fn cancel_planned_upgrade(&mut self) { + let updater = self.planned_updater_accessor().get(&self.sdk); + let planned_target_addresses = self.planned_target_addresses_accessor(); + let planned_wasm_hashes = self.planned_wasm_hashes_accessor(); + let hashes_len = planned_wasm_hashes.len(&self.sdk); + if updater == Address::ZERO && hashes_len == 0 { + return; + } + + let mut target_addresses = Vec::new(); + let mut wasm_code_hashes = Vec::new(); + for index in 0..hashes_len { + target_addresses.push(planned_target_addresses.at(index).get(&self.sdk)); + wasm_code_hashes.push(planned_wasm_hashes.at(index).get(&self.sdk)); + } + + let genesis_hash = self.planned_genesis_hash_accessor().get(&self.sdk); + self.clear_planned_hashes(); + self.planned_updater_accessor() + .set(&mut self.sdk, Address::ZERO); + self.planned_genesis_hash_accessor() + .set(&mut self.sdk, B256::ZERO); + self.planned_genesis_version_accessor() + .clear(&mut self.sdk) + .expect("runtime-upgrade: can't clear planned genesis version"); + + UpgradePlanCancelled { + genesis_hash, + updater, + target_addresses, + wasm_code_hashes, + } + .emit(&mut self.sdk) + .unwrap(); } fn clear_planned_hashes(&mut self) { diff --git a/contracts/runtime-upgrade/src/tests.rs b/contracts/runtime-upgrade/src/tests.rs index 13bcfce2b..52bb3e5d6 100644 --- a/contracts/runtime-upgrade/src/tests.rs +++ b/contracts/runtime-upgrade/src/tests.rs @@ -36,6 +36,124 @@ impl Harness { _ = self.sdk.take_output(); exit_code } + + /// Inspect or drive contract state directly, bypassing the router. + fn with_app(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let mut app = App::new(core::mem::take(&mut self.sdk)); + let result = f(&mut app); + self.sdk = app.sdk; + result + } + + fn owner(&mut self) -> Address { + self.with_app(|app| app.owner_accessor().get(&app.sdk)) + } + + fn planned_updater(&mut self) -> Address { + self.with_app(|app| app.planned_updater_accessor().get(&app.sdk)) + } + + fn planned_genesis(&mut self) -> (B256, String) { + self.with_app(|app| { + ( + app.planned_genesis_hash_accessor().get(&app.sdk), + app.planned_genesis_version_accessor().get(&app.sdk), + ) + }) + } + + fn planned_len(&mut self) -> u64 { + self.with_app(|app| app.planned_wasm_hashes_accessor().len(&app.sdk)) + } + + fn has_planned(&mut self, target_address: Address, wasm_code_hash: B256) -> bool { + self.with_app(|app| app.has_planned_upgrade(target_address, wasm_code_hash)) + } + + /// Consume a planned pair the way a successful `upgradeToPlanned` does. The install path + /// itself needs a real runtime syscall, which the testing context does not provide. + fn consume_planned(&mut self, target_address: Address, wasm_code_hash: B256) { + self.with_app(|app| app.remove_planned_upgrade(target_address, wasm_code_hash)); + } +} + +const OWNER: Address = DEFAULT_UPDATE_GENESIS_AUTH; +const NEW_OWNER: Address = address!("4444444444444444444444444444444444444444"); +const UPDATER: Address = address!("1111111111111111111111111111111111111111"); +const TARGET_A: Address = address!("2222222222222222222222222222222222222222"); +const TARGET_B: Address = address!("3333333333333333333333333333333333333333"); + +/// Minimal valid WASM (magic bytes + version) with a trailing byte to vary the hash. +fn wasm_for(target_address: Address) -> Bytes { + Bytes::from(vec![ + 0x00, + 0x61, + 0x73, + 0x6d, + 0x01, + 0x00, + 0x00, + 0x00, + target_address.0[0], + ]) +} + +fn plan_two_targets(h: &mut Harness) -> (B256, B256) { + let hash_a = crypto_keccak256(wasm_for(TARGET_A).as_ref()); + let hash_b = crypto_keccak256(wasm_for(TARGET_B).as_ref()); + + h.set_caller(OWNER); + let plan_call = PlanUpgradeCall::new(( + B256::from([0xab; 32]), + "v1.0.0".to_string(), + vec![TARGET_A, TARGET_B], + vec![hash_a, hash_b], + UPDATER, + )); + assert_eq!(h.call(plan_call.encode()), ExitCode::Ok); + + (hash_a, hash_b) +} + +fn assert_plan_is_cancelled(h: &mut Harness, hash_a: B256, hash_b: B256) { + assert_eq!( + h.planned_updater(), + Address::ZERO, + "updater still delegated" + ); + assert_eq!(h.planned_len(), 0, "planned pairs survived"); + assert!(!h.has_planned(TARGET_A, hash_a)); + assert!(!h.has_planned(TARGET_B, hash_b)); + assert_eq!( + h.planned_genesis(), + (B256::ZERO, String::new()), + "genesis metadata survived" + ); + + // The previously delegated updater can no longer consume any leftover of the old plan. + h.set_caller(UPDATER); + for (target, wasm) in [ + (TARGET_A, wasm_for(TARGET_A)), + (TARGET_B, wasm_for(TARGET_B)), + ] { + let upgrade_call = UpgradeToPlannedCall::new((target, wasm)); + assert_eq!( + h.call(upgrade_call.encode()), + ExitCode::Panic, + "delegated updater still authorized for {target}" + ); + } +} + +fn assert_plan_is_intact(h: &mut Harness, hash_a: B256, hash_b: B256) { + assert_eq!(h.planned_updater(), UPDATER); + assert_eq!(h.planned_len(), 2); + assert!(h.has_planned(TARGET_A, hash_a)); + assert!(h.has_planned(TARGET_B, hash_b)); + assert_eq!( + h.planned_genesis(), + (B256::from([0xab; 32]), "v1.0.0".to_string()) + ); } #[test] @@ -131,6 +249,35 @@ fn test_plan_upgrade_encoding() { assert_eq!(decoded.0 .4, updater, "updater mismatch"); } +#[test] +fn test_non_owner_plan_upgrade_rejects_count_larger_than_array_body() { + let call = PlanUpgradeCall::new(( + B256::from([0xab; 32]), + "v1.0.0".to_string(), + vec![address!("2222222222222222222222222222222222222222")], + vec![B256::from([0x11; 32])], + address!("1111111111111111111111111111111111111111"), + )); + let mut encoded = call.encode().to_vec(); + + // The third planUpgrade argument is target_addresses. Its ABI head starts after the selector, + // genesis hash, and genesis version offset. Replace its body count while keeping the body tiny. + let target_addresses_head = 4 + 64; + let target_addresses_offset = u32::from_be_bytes( + encoded[target_addresses_head + 28..target_addresses_head + 32] + .try_into() + .expect("target_addresses offset must be a u32"), + ) as usize; + let target_addresses_length = 4 + target_addresses_offset; + encoded[target_addresses_length + 28..target_addresses_length + 32] + .copy_from_slice(&u32::MAX.to_be_bytes()); + + assert!(PlanUpgradeCall::decode(&&encoded[4..]).is_err()); + + let mut h = Harness::new(); + assert_eq!(h.call(Bytes::from(encoded)), ExitCode::Panic); +} + #[test] fn test_upgrade_to_planned_encoding() { let target = address!("2222222222222222222222222222222222222222"); @@ -158,6 +305,164 @@ fn test_upgrade_and_recompile_event_signatures_are_distinct() { assert_ne!(RuntimeUpgraded::SELECTOR, ContractRecompiled::SELECTOR); } +#[test] +fn test_upgrade_plan_cancelled_event_signature_is_distinct() { + assert_eq!( + UpgradePlanCancelled::SIGNATURE, + "UpgradePlanCancelled(bytes32,address,address[],bytes32[])" + ); + assert_ne!(UpgradePlanCancelled::SELECTOR, UpgradePlanned::SELECTOR); +} + +#[test] +fn test_change_owner_revokes_planned_upgrade() { + let mut h = Harness::new(); + let (hash_a, hash_b) = plan_two_targets(&mut h); + assert_plan_is_intact(&mut h, hash_a, hash_b); + _ = h.sdk.take_logs(); + + h.set_caller(OWNER); + assert_eq!( + h.call(ChangeOwnerCall::new((NEW_OWNER,)).encode()), + ExitCode::Ok + ); + assert_eq!(h.owner(), NEW_OWNER); + + let logs = h.sdk.take_logs(); + assert_eq!(logs.len(), 2, "expected cancellation then owner change"); + assert_eq!(logs[0].1[0].0, UpgradePlanCancelled::SELECTOR); + assert_eq!(logs[1].1[0].0, OwnerChanged::SELECTOR); + let (genesis_hash, updater, target_addresses, wasm_code_hashes) = + log_data_abi::decode_upgrade_plan_cancelled(&logs[0].1, &logs[0].0); + assert_eq!(genesis_hash, B256::from([0xab; 32])); + assert_eq!(updater, UPDATER); + assert_eq!(target_addresses, vec![TARGET_A, TARGET_B]); + assert_eq!(wasm_code_hashes, vec![hash_a, hash_b]); + + assert_plan_is_cancelled(&mut h, hash_a, hash_b); +} + +#[test] +fn test_renounce_ownership_revokes_planned_upgrade() { + let mut h = Harness::new(); + let (hash_a, hash_b) = plan_two_targets(&mut h); + + h.set_caller(OWNER); + assert_eq!( + h.call(RenounceOwnershipCall::new(()).encode()), + ExitCode::Ok + ); + assert_eq!(h.owner(), SYSTEM_ADDRESS); + + assert_plan_is_cancelled(&mut h, hash_a, hash_b); +} + +#[test] +fn test_partially_consumed_plan_is_revoked_on_owner_change() { + let mut h = Harness::new(); + let (hash_a, hash_b) = plan_two_targets(&mut h); + + // The delegated updater installs the first pair; the second one is still pending. + h.consume_planned(TARGET_A, hash_a); + assert_eq!(h.planned_len(), 1); + assert!(!h.has_planned(TARGET_A, hash_a)); + assert!(h.has_planned(TARGET_B, hash_b)); + + h.set_caller(OWNER); + assert_eq!( + h.call(ChangeOwnerCall::new((NEW_OWNER,)).encode()), + ExitCode::Ok + ); + + assert_plan_is_cancelled(&mut h, hash_a, hash_b); +} + +#[test] +fn test_new_owner_plans_without_inheriting_stale_entries() { + let mut h = Harness::new(); + let (hash_a, hash_b) = plan_two_targets(&mut h); + + h.set_caller(OWNER); + assert_eq!( + h.call(ChangeOwnerCall::new((NEW_OWNER,)).encode()), + ExitCode::Ok + ); + + // The old owner cannot plan anymore, and the new owner's plan covers only its own pair. + let new_updater = address!("5555555555555555555555555555555555555555"); + let fresh_plan = PlanUpgradeCall::new(( + B256::from([0xcd; 32]), + "v2.0.0".to_string(), + vec![TARGET_A], + vec![hash_a], + new_updater, + )); + assert_eq!(h.call(fresh_plan.encode()), ExitCode::Panic); + + h.set_caller(NEW_OWNER); + assert_eq!(h.call(fresh_plan.encode()), ExitCode::Ok); + + assert_eq!(h.planned_len(), 1); + assert!(h.has_planned(TARGET_A, hash_a)); + assert!(!h.has_planned(TARGET_B, hash_b)); + assert_eq!(h.planned_updater(), new_updater); + assert_eq!( + h.planned_genesis(), + (B256::from([0xcd; 32]), "v2.0.0".to_string()) + ); + + // The updater delegated by the previous owner has no authority under the new plan. + h.set_caller(UPDATER); + let upgrade_call = UpgradeToPlannedCall::new((TARGET_A, wasm_for(TARGET_A))); + assert_eq!(h.call(upgrade_call.encode()), ExitCode::Panic); +} + +#[test] +fn test_reverted_ownership_transition_keeps_plan_intact() { + let mut h = Harness::new(); + let (hash_a, hash_b) = plan_two_targets(&mut h); + + // Zero-address transfer is rejected after the plan would otherwise have been cancelled. + h.set_caller(OWNER); + assert_eq!( + h.call(ChangeOwnerCall::new((Address::ZERO,)).encode()), + ExitCode::Panic + ); + assert_eq!(h.owner(), Address::ZERO, "owner slot must stay untouched"); + assert_plan_is_intact(&mut h, hash_a, hash_b); + + // Neither can a non-owner drop the plan via a failed transition. + h.set_caller(UPDATER); + assert_eq!( + h.call(ChangeOwnerCall::new((NEW_OWNER,)).encode()), + ExitCode::Panic + ); + assert_eq!( + h.call(RenounceOwnershipCall::new(()).encode()), + ExitCode::Panic + ); + assert_plan_is_intact(&mut h, hash_a, hash_b); + + // The plan is still usable by its delegated updater. + assert_eq!(h.planned_updater(), UPDATER); +} + +#[test] +fn test_ownership_transition_without_plan_emits_no_cancellation() { + let mut h = Harness::new(); + + h.set_caller(OWNER); + _ = h.sdk.take_logs(); + assert_eq!( + h.call(ChangeOwnerCall::new((NEW_OWNER,)).encode()), + ExitCode::Ok + ); + + let logs = h.sdk.take_logs(); + assert_eq!(logs.len(), 1, "expected only OwnerChanged"); + assert_eq!(logs[0].1[0].0, OwnerChanged::SELECTOR); +} + #[test] fn test_planned_upgrade_rejects_same_hash_for_wrong_target() { let planned_target = address!("2222222222222222222222222222222222222222"); @@ -181,3 +486,353 @@ fn test_planned_upgrade_rejects_same_hash_for_wrong_target() { let upgrade_call = UpgradeToPlannedCall::new((wrong_target, wasm_bytecode)); assert_ne!(h.call(upgrade_call.encode()), ExitCode::Ok); } + +/// Log-data ABI vectors. +/// +/// Solidity encodes event data with top-level argument semantics (`abi.encode(a, b, ...)`), so +/// dynamic non-indexed fields must NOT be wrapped in an outer tuple offset. Every expected vector +/// below is written out word by word, independently of the encoder, and cross-checked against +/// `alloy-sol-types` — a standard log decoder — so a regression to tuple-value encoding fails here. +mod log_data_abi { + use super::*; + use alloy_sol_types::SolEvent; + use fluentbase_sdk::U256; + + /// The same events declared in Solidity. Names must match the Rust ones: the decoder checks + /// `topics[0]` against the signature hash it derives itself. + mod sol_abi { + alloy_sol_types::sol! { + event RuntimeUpgraded( + address indexed target_address, + bytes32 indexed genesis_hash, + string genesis_version, + bytes32 code_hash + ); + + event UpgradePlanned( + bytes32 indexed genesis_hash, + string genesis_version, + address[] target_addresses, + bytes32[] wasm_code_hashes, + address updater + ); + + event UpgradePlanCancelled( + bytes32 indexed genesis_hash, + address updater, + address[] target_addresses, + bytes32[] wasm_code_hashes + ); + + event OwnerChanged(address new_owner); + + event Mixed(address indexed who, uint256 amount, string note, address tail); + } + } + + /// A non-indexed field placed after a dynamic one: its head word must stay in place instead of + /// sliding behind a wrapper offset. + #[derive(Event)] + struct Mixed { + #[indexed] + who: Address, + amount: U256, + note: String, + tail: Address, + } + + /// Emits a single event into a throwaway context and returns `(topics, data)`. + pub(super) fn emitted(emit: impl FnOnce(&mut TestingContextImpl)) -> (Vec, Bytes) { + let mut sdk = TestingContextImpl::default(); + emit(&mut sdk); + let mut logs = sdk.take_logs(); + assert_eq!(logs.len(), 1, "expected exactly one log"); + let (data, topics) = logs.remove(0); + (topics, data) + } + + pub(super) fn decode_upgrade_plan_cancelled( + topics: &[B256], + data: &[u8], + ) -> (B256, Address, Vec
, Vec) { + let decoded = sol_abi::UpgradePlanCancelled::decode_raw_log(topics, data) + .expect("standard cancellation event decoder"); + ( + decoded.genesis_hash, + decoded.updater, + decoded.target_addresses, + decoded.wasm_code_hashes, + ) + } + + /// Concatenates 32-byte words into the expected `data` blob. + fn words(words: &[[u8; 32]]) -> Vec { + words.concat() + } + + fn word_u64(value: u64) -> [u8; 32] { + let mut word = [0u8; 32]; + word[24..].copy_from_slice(&value.to_be_bytes()); + word + } + + fn word_address(address: Address) -> [u8; 32] { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(address.as_slice()); + word + } + + fn word_bytes32(value: B256) -> [u8; 32] { + value.0 + } + + /// Right-pads a string to a single 32-byte word (all fixtures here are shorter than 32 bytes). + fn word_utf8(text: &str) -> [u8; 32] { + assert!(text.len() <= 32, "fixture string must fit one word"); + let mut word = [0u8; 32]; + word[..text.len()].copy_from_slice(text.as_bytes()); + word + } + + #[test] + fn runtime_upgraded_data_matches_solidity_argument_encoding() { + let target_address = address!("1111111111111111111111111111111111111111"); + let genesis_hash = B256::repeat_byte(0x22); + let genesis_version = "v1.2.3".to_string(); + let code_hash = B256::repeat_byte(0x33); + + let (topics, data) = emitted(|sdk| { + RuntimeUpgraded { + target_address, + genesis_hash, + genesis_version: genesis_version.clone(), + code_hash, + } + .emit(sdk) + .unwrap() + }); + + // head: [offset(genesis_version), code_hash], tail: [len, utf8] + let expected = words(&[ + word_u64(0x40), + word_bytes32(code_hash), + word_u64(genesis_version.len() as u64), + word_utf8(&genesis_version), + ]); + assert_eq!(hex::encode(&data), hex::encode(&expected)); + + let decoded = + sol_abi::RuntimeUpgraded::decode_raw_log(topics, &data).expect("standard decoder"); + assert_eq!(decoded.target_address, target_address); + assert_eq!(decoded.genesis_hash, genesis_hash); + assert_eq!(decoded.genesis_version, genesis_version); + assert_eq!(decoded.code_hash, code_hash); + } + + #[test] + fn upgrade_planned_data_matches_solidity_argument_encoding() { + let genesis_hash = B256::repeat_byte(0x44); + let genesis_version = "planned".to_string(); + let target_a = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let target_b = address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let hash_a = B256::repeat_byte(0xcc); + let hash_b = B256::repeat_byte(0xdd); + let updater = address!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + + let (topics, data) = emitted(|sdk| { + UpgradePlanned { + genesis_hash, + genesis_version: genesis_version.clone(), + target_addresses: vec![target_a, target_b], + wasm_code_hashes: vec![hash_a, hash_b], + updater, + } + .emit(sdk) + .unwrap() + }); + + // head: [off(version)=0x80, off(targets)=0xc0, off(hashes)=0x120, updater] + let expected = words(&[ + word_u64(0x80), + word_u64(0xc0), + word_u64(0x120), + word_address(updater), + word_u64(genesis_version.len() as u64), + word_utf8(&genesis_version), + word_u64(2), + word_address(target_a), + word_address(target_b), + word_u64(2), + word_bytes32(hash_a), + word_bytes32(hash_b), + ]); + assert_eq!(hex::encode(&data), hex::encode(&expected)); + + let decoded = + sol_abi::UpgradePlanned::decode_raw_log(topics, &data).expect("standard decoder"); + assert_eq!(decoded.genesis_hash, genesis_hash); + assert_eq!(decoded.genesis_version, genesis_version); + assert_eq!(decoded.target_addresses, vec![target_a, target_b]); + assert_eq!(decoded.wasm_code_hashes, vec![hash_a, hash_b]); + assert_eq!(decoded.updater, updater); + } + + #[test] + fn mixed_static_and_dynamic_data_matches_solidity_argument_encoding() { + let who = address!("1010101010101010101010101010101010101010"); + let amount = U256::from(0x1234u64); + let note = "mixed".to_string(); + let tail = address!("2020202020202020202020202020202020202020"); + + let (topics, data) = emitted(|sdk| { + Mixed { + who, + amount, + note: note.clone(), + tail, + } + .emit(sdk) + .unwrap() + }); + + // head: [amount, off(note)=0x60, tail], tail: [len, utf8] + let expected = words(&[ + word_u64(0x1234), + word_u64(0x60), + word_address(tail), + word_u64(note.len() as u64), + word_utf8(¬e), + ]); + assert_eq!(hex::encode(&data), hex::encode(&expected)); + + let decoded = sol_abi::Mixed::decode_raw_log(topics, &data).expect("standard decoder"); + assert_eq!(decoded.who, who); + assert_eq!(decoded.amount, amount); + assert_eq!(decoded.note, note); + assert_eq!(decoded.tail, tail); + } + + #[test] + fn fully_static_data_has_no_offset_word() { + let new_owner = address!("3030303030303030303030303030303030303030"); + + let (topics, data) = emitted(|sdk| OwnerChanged { new_owner }.emit(sdk).unwrap()); + + assert_eq!( + hex::encode(&data), + hex::encode(words(&[word_address(new_owner)])) + ); + + let decoded = + sol_abi::OwnerChanged::decode_raw_log(topics, &data).expect("standard decoder"); + assert_eq!(decoded.new_owner, new_owner); + } +} + +/// Indexed-topic ABI vectors. +/// +/// Solidity gives indexed reference types — `string`, `bytes`, arrays (fixed ones included) and +/// structs — their own encoding: the topic is `keccak256` of a preimage with no length prefix and +/// no head/tail offsets, not `keccak256` of ordinary ABI encoding. Every expectation below comes +/// from `alloy-sol-types`, an independent implementation of that rule, and each log is then run +/// through a standard decoder so a regression to ordinary ABI encoding fails here. +mod log_topic_abi { + use super::{log_data_abi::emitted, *}; + use alloy_sol_types::{sol_data, EventTopic, SolEvent}; + use fluentbase_sdk::U256; + + /// The same events declared in Solidity. + mod sol_abi { + alloy_sol_types::sol! { + event Tagged( + string indexed label, + bytes indexed payload, + uint256[] indexed ids, + uint256 value + ); + + event Batched(address[2] indexed recipients, uint256 total); + } + } + + #[derive(Event)] + struct Tagged { + #[indexed] + label: String, + #[indexed] + payload: Bytes, + #[indexed] + ids: Vec, + value: U256, + } + + /// A fixed array is static, so ordinary ABI encoding leaves it in place — but it is still a + /// reference type, and its topic is a hash rather than its first word. + #[derive(Event)] + struct Batched { + #[indexed] + recipients: [Address; 2], + total: U256, + } + + #[test] + fn indexed_dynamic_topics_match_solidity_indexed_encoding() { + let label = "genesis".to_string(); + let payload = bytes!("c0ffee"); + let ids = vec![U256::from(7), U256::from(9)]; + let value = U256::from(42); + + let (topics, data) = emitted(|sdk| { + Tagged { + label: label.clone(), + payload: payload.clone(), + ids: ids.clone(), + value, + } + .emit(sdk) + .unwrap() + }); + + assert_eq!(topics.len(), 4); + assert_eq!(topics[0], sol_abi::Tagged::SIGNATURE_HASH); + assert_eq!( + topics[1], + ::encode_topic(&label).0 + ); + assert_eq!( + topics[2], + ::encode_topic(&payload).0 + ); + assert_eq!( + topics[3], + > as EventTopic>::encode_topic(&ids).0 + ); + + let decoded = sol_abi::Tagged::decode_raw_log(topics, &data).expect("standard decoder"); + assert_eq!(decoded.value, value); + } + + #[test] + fn indexed_fixed_array_topic_is_hashed_not_truncated() { + let recipients = [ + address!("1111111111111111111111111111111111111111"), + address!("2222222222222222222222222222222222222222"), + ]; + let total = U256::from(3); + + let (topics, data) = emitted(|sdk| Batched { recipients, total }.emit(sdk).unwrap()); + + assert_eq!(topics.len(), 2); + assert_eq!(topics[0], sol_abi::Batched::SIGNATURE_HASH); + assert_eq!( + topics[1], + as EventTopic>::encode_topic(&recipients).0 + ); + + // The old encoding copied the first word, which is the first recipient, left-padded. + assert_ne!(topics[1], B256::left_padding_from(recipients[0].as_slice())); + + let decoded = sol_abi::Batched::decode_raw_log(topics, &data).expect("standard decoder"); + assert_eq!(decoded.total, total); + } +} diff --git a/contracts/universal-token/src/lib.rs b/contracts/universal-token/src/lib.rs index 7145c75d4..8fa2037b6 100644 --- a/contracts/universal-token/src/lib.rs +++ b/contracts/universal-token/src/lib.rs @@ -19,9 +19,17 @@ use fluentbase_sdk::{ storage::{StorageMap, StorageU256}, system_entrypoint, universal_token::*, - Address, Bytes, ContextReader, EvmExitCode, ExitCode, StorageUtils, SystemAPI, U256, + Address, Bytes, ContextReader, EvmExitCode, ExitCode, StorageUtils, SystemAPI, FUEL_DENOM_RATE, + U256, }; +/// EVM `CODEDEPOSIT` price, charged per byte of code a creation persists. +/// +/// Token metadata is committed as the created account's code, so it is priced the same way the +/// EVM runtime prices deployed bytecode (see `contracts/evm`, which charges this against its own +/// gas before writing metadata). +const CODE_DEPOSIT_GAS_PER_BYTE: u64 = 200; + mod events { use super::*; @@ -706,7 +714,18 @@ fn erc20_constructor_handler( when_non_payable!(sdk); when_non_static!(sdk); - // Decode initial settings parameters (SolidityABI) + // Decode initial settings parameters (SolidityABI). Decoding accepts only the exact canonical + // payload forms, so `input` carries no bytes without token semantics. + let settings = + InitialSettings::decode_with_prefix(&input).ok_or(ExitCode::MalformedBuiltinParams)?; + + // Persist a canonical re-encoding rather than the creation input itself. The two are + // byte-identical for V1/V2 payloads; a legacy payload collapses to its much smaller V1 form. + let metadata = settings.encode_with_prefix(); + if metadata.len() > INITIAL_SETTINGS_V2_SIZE { + return Err(ExitCode::CreateContractSizeLimit); + } + let InitialSettings { token_name, token_symbol, @@ -715,21 +734,18 @@ fn erc20_constructor_handler( minter, pauser, wrapped, - } = InitialSettings::decode_with_prefix(&input).ok_or(ExitCode::MalformedBuiltinParams)?; - - // Write token name and token decimals (make sure both are properly UTF-8 encoded) - sdk.write_storage_short_string( - NAME_STORAGE_SLOT, - token_name - .as_str() - .ok_or(ExitCode::MalformedBuiltinParams)?, - )?; - sdk.write_storage_short_string( - SYMBOL_STORAGE_SLOT, - token_symbol - .as_str() - .ok_or(ExitCode::MalformedBuiltinParams)?, - )?; + } = settings; + + // Decode both metadata fields before writing either one, so a malformed symbol can + // never leave a half-initialized token with its name already committed. + let token_name = token_name + .as_str() + .ok_or(ExitCode::MalformedBuiltinParams)?; + let token_symbol = token_symbol + .as_str() + .ok_or(ExitCode::MalformedBuiltinParams)?; + sdk.write_storage_short_string(NAME_STORAGE_SLOT, token_name)?; + sdk.write_storage_short_string(SYMBOL_STORAGE_SLOT, token_symbol)?; // We should store decimals in the storage sdk.write_storage(DECIMALS_STORAGE_SLOT, U256::from(decimals)) .ok()?; @@ -771,14 +787,26 @@ fn erc20_constructor_handler( sdk.write_storage(WRAPPED_STORAGE_SLOT, U256::from(wrapped)) .ok()?; } - // Copy initial settings into metadata - sdk.write_contract_metadata(input); + // Metadata becomes the created account's code, so pay for it at the EVM code-deposit rate + // before committing it. + let fuel_for_metadata = (metadata.len() as u64) + .saturating_mul(CODE_DEPOSIT_GAS_PER_BYTE) + .saturating_mul(FUEL_DENOM_RATE); + if sdk.fuel() < fuel_for_metadata { + return Err(ExitCode::OutOfFuel); + } + sdk.charge_fuel(fuel_for_metadata); + + // Copy canonical initial settings into metadata + sdk.write_contract_metadata(metadata); Ok(0) } pub fn deploy_entry(sdk: &mut SDK) -> Result<(), ExitCode> { let input_size = sdk.input_size(); - if input_size < SIG_LEN_BYTES as u32 { + // Bound the payload before reading it: no accepted creation form is larger than the legacy + // one, so a bigger input can only be a payload padded with bytes this runtime would ignore. + if input_size < SIG_LEN_BYTES as u32 || input_size as usize > INITIAL_SETTINGS_MAX_SIZE { return Err(ExitCode::MalformedBuiltinParams); } let input = sdk.bytes_input(); diff --git a/contracts/universal-token/src/tests.rs b/contracts/universal-token/src/tests.rs index 222f4169e..1c77df861 100644 --- a/contracts/universal-token/src/tests.rs +++ b/contracts/universal-token/src/tests.rs @@ -11,8 +11,8 @@ use fluentbase_sdk::{ evm::write_evm_exit_message, storage::{StorageMap, StorageU256}, universal_token::*, - Address, Bytes, ContextReader, ContractContextV1, ExitCode, SharedAPI, B256, FUEL_DENOM_RATE, - PRECOMPILE_UNIVERSAL_TOKEN_RUNTIME, U256, + Address, Bytes, ContextReader, ContractContextV1, ExitCode, SharedAPI, StorageAPI, B256, + FUEL_DENOM_RATE, PRECOMPILE_UNIVERSAL_TOKEN_RUNTIME, U256, }; use fluentbase_testing::TestingContextImpl; @@ -265,7 +265,10 @@ struct Harness { impl Harness { fn new(token_address: Address) -> Self { - let gas_limit = 120_000; + Self::new_with_gas_limit(token_address, 120_000) + } + + fn new_with_gas_limit(token_address: Address, gas_limit: u64) -> Self { let sdk = TestingContextImpl::default() .with_contract_context(ContractContextV1 { address: token_address, @@ -1049,6 +1052,111 @@ fn calldata_prefix_is_big_endian_selector() { assert_eq!(&cd[4..], &data); } +/// Builds a metadata word from raw bytes, bypassing the encoder's validation the way +/// hand-crafted calldata or a legacy payload would. +fn raw_metadata_word(bytes: &[u8]) -> TokenNameOrSymbol { + assert!(bytes.len() <= 32); + let mut word = B256::ZERO; + word[..bytes.len()].copy_from_slice(bytes); + TokenNameOrSymbol::from_word(word) +} + +#[test] +fn deploy_accepts_metadata_at_the_32_byte_boundary() { + let token = Address::with_last_byte(1); + let deployer = Address::with_last_byte(2); + let mut h = Harness::new(token); + + let long_name = "a".repeat(32); + let multibyte_symbol = "😀".repeat(8); // exactly 32 bytes + + let mut s = InitialSettings::default(); + s.token_name = TokenNameOrSymbol::from_str(&long_name); + s.token_symbol = TokenNameOrSymbol::from_str(&multibyte_symbol); + s.decimals = 18; + let (ec, _) = h.deploy(s.encode_with_prefix(), deployer); + assert_eq!(ec, ExitCode::Ok, "32-byte metadata must deploy"); + + let (ec, out) = h.call(with_sig(SIG_ERC20_NAME, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_string(&out), long_name); + + let (ec, out) = h.call(with_sig(SIG_ERC20_SYMBOL, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_string(&out), multibyte_symbol); +} + +#[test] +fn deploy_rejects_malformed_metadata_without_persisting_it() { + // A 32-byte word ending mid-code-point: what a naive 32-byte truncation of a longer + // name produces. The symbol is well-formed, so this also pins down that the name is + // rejected before *either* slot is written. + let mut split_multibyte = "a".repeat(31).into_bytes(); + split_multibyte.push(0xC3); // leading byte of `é`, continuation byte missing + + for (name, symbol) in [ + ( + raw_metadata_word(&split_multibyte), + TokenNameOrSymbol::from_str("SYM"), + ), + ( + TokenNameOrSymbol::from_str("Token"), + raw_metadata_word(&[0x80]), // lone continuation byte + ), + ] { + let token = Address::with_last_byte(1); + let deployer = Address::with_last_byte(2); + let mut h = Harness::new(token); + + let mut s = InitialSettings::default(); + s.token_name = name; + s.token_symbol = symbol; + s.decimals = 18; + let (ec, _) = h.deploy(s.encode_with_prefix(), deployer); + assert_eq!( + ec, + ExitCode::MalformedBuiltinParams, + "malformed metadata must revert the constructor" + ); + + // Nothing was persisted, so reads stay live instead of panicking. + let (ec, out) = h.call(with_sig(SIG_ERC20_NAME, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_string(&out), ""); + + let (ec, out) = h.call(with_sig(SIG_ERC20_SYMBOL, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_string(&out), ""); + } +} + +#[test] +fn malformed_stored_metadata_errors_instead_of_panicking() { + let token = Address::with_last_byte(1); + let deployer = Address::with_last_byte(2); + let mut h = Harness::new(token); + deploy_with_supply_to(&mut h, deployer, U256::ZERO); + + // Simulate a token whose name slot already holds invalid UTF-8 (genesis state or a + // pre-fix deployment). Reads must return a deterministic error, not a panic. + let mut word = [0u8; 32]; + word[0] = 0x80; + h.sdk + .write_storage(NAME_STORAGE_SLOT, U256::from_be_bytes(word)); + + let (ec, _) = h.call(with_sig(SIG_ERC20_NAME, &[])); + assert_eq!(ec, ExitCode::MalformedBuiltinParams); + + // The permit domain separator reads the same slot and must fail the same way. + let (ec, _) = h.call(with_sig(SIG_ERC20_DOMAIN_SEPARATOR, &[])); + assert_eq!(ec, ExitCode::MalformedBuiltinParams); + + // Unrelated paths keep working: the poison is scoped to the name slot. + let (ec, out) = h.call(with_sig(SIG_ERC20_SYMBOL, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_string(&out), "TST"); +} + #[test] fn name_symbol_decimals_are_correctly_abi_encoded() { let token = Address::with_last_byte(1); @@ -1950,3 +2058,217 @@ fn secp256k1_half_order_constant_matches_eip2_boundary() { assert_eq!(crate::erc2612::SECP256K1N_HALF, expected); assert!(crate::erc2612::SECP256K1N_HALF < high_s); } + +/// Creation-payload canonicality. +/// +/// A creation payload becomes the account's metadata, and metadata is committed as account code. +/// The constructor therefore accepts only the exact canonical payload forms and persists a +/// re-encoding of what it decoded, so no byte without token semantics can reach state. +mod canonical_creation_payload { + use super::*; + use fluentbase_sdk::{ + bytes::BytesMut, SystemAPI, EVM_MAX_INITCODE_SIZE, UNIVERSAL_TOKEN_MAGIC_BYTES, + }; + + const TOKEN: Address = Address::with_last_byte(1); + const DEPLOYER: Address = Address::with_last_byte(2); + + fn canonical_v1() -> Bytes { + let mut s = InitialSettings::default(); + s.token_name = "TestToken".into(); + s.token_symbol = "TST".into(); + s.decimals = 18; + s.initial_supply = U256::from(1_000u64); + s.encode_with_prefix() + } + + fn canonical_v2() -> Bytes { + let mut s = InitialSettings::default(); + s.token_name = "Wrapped".into(); + s.token_symbol = "WRP".into(); + s.decimals = 18; + s.wrapped = Some(true); + s.encode_with_prefix() + } + + /// The pre-V1 layout: name and symbol as `[u8; 32]`, one word per byte. + fn canonical_legacy() -> Bytes { + let mut token_name = [0u8; 32]; + token_name[.."Legacy".len()].copy_from_slice(b"Legacy"); + let mut token_symbol = [0u8; 32]; + token_symbol[.."LGC".len()].copy_from_slice(b"LGC"); + + let settings = LegacyInitialSettings { + token_name, + token_symbol, + decimals: 8, + initial_supply: U256::from(42u64), + minter: Address::ZERO, + pauser: Address::ZERO, + }; + let mut payload = BytesMut::new(); + SolidityABI::encode(&settings, &mut payload, 0).unwrap(); + + let mut out = Vec::with_capacity(UNIVERSAL_TOKEN_MAGIC_BYTES.len() + payload.len()); + out.extend_from_slice(&UNIVERSAL_TOKEN_MAGIC_BYTES[..]); + out.extend_from_slice(&payload); + out.into() + } + + fn forms() -> Vec<(&'static str, Bytes)> { + vec![ + ("v1", canonical_v1()), + ("v2", canonical_v2()), + ("legacy", canonical_legacy()), + ] + } + + /// Asserts a rejected creation left no account state behind. + fn assert_creation_rejected(input: Bytes, label: &str) { + let mut h = Harness::new(TOKEN); + let (ec, _) = h.deploy(input, DEPLOYER); + assert_eq!( + ec, + ExitCode::MalformedBuiltinParams, + "{label} must be rejected" + ); + + assert!( + h.sdk.contract_metadata().is_empty(), + "{label} persisted metadata" + ); + assert!(h.take_logs().is_empty(), "{label} emitted logs"); + + // Storage is untouched: every read still returns the empty-account value. + let (ec, out) = h.call(with_sig(SIG_ERC20_NAME, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_string(&out), ""); + let (ec, out) = h.call(with_sig(SIG_ERC20_TOTAL_SUPPLY, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_u256_word(&out), U256::ZERO); + } + + #[test] + fn canonical_payloads_still_deploy() { + for (label, input) in forms() { + let mut h = Harness::new(TOKEN); + let (ec, _) = h.deploy(input, DEPLOYER); + assert_eq!(ec, ExitCode::Ok, "{label} payload must deploy"); + } + } + + #[test] + fn one_trailing_byte_rejects_the_creation() { + for (label, input) in forms() { + let mut padded = input.to_vec(); + padded.push(0); + assert_creation_rejected(padded.into(), &format!("{label} + 1 byte")); + } + } + + #[test] + fn non_canonical_boundary_lengths_are_rejected() { + let legacy = canonical_legacy(); + for size in [ + INITIAL_SETTINGS_V1_SIZE, + INITIAL_SETTINGS_V2_SIZE, + INITIAL_SETTINGS_LEGACY_SIZE, + ] { + for len in [size - 1, size + 1] { + // A V1/V2 length is a real canonical form, so only test the lengths adjacent to + // one that no form occupies. + if [ + INITIAL_SETTINGS_V1_SIZE, + INITIAL_SETTINGS_V2_SIZE, + INITIAL_SETTINGS_LEGACY_SIZE, + ] + .contains(&len) + { + continue; + } + let mut input = legacy[..len.min(legacy.len())].to_vec(); + input.resize(len, 0); + assert_creation_rejected(input.into(), &format!("payload of {len} bytes")); + } + } + } + + #[test] + fn maximum_creation_input_is_rejected() { + // The largest payload EIP-3860 lets a creator submit, prefixed so it routes to this + // runtime. Before exact-length decoding, a payload like this decoded as legacy and its + // whole body was persisted as account code. + let legacy = canonical_legacy(); + let mut input = legacy.to_vec(); + input.resize(EVM_MAX_INITCODE_SIZE, 0); + assert_eq!(input[..4], UNIVERSAL_TOKEN_MAGIC_BYTES); + assert_creation_rejected(input.into(), "maximum creation input"); + } + + #[test] + fn persisted_metadata_is_the_canonical_encoding() { + for (label, input) in forms() { + let mut h = Harness::new(TOKEN); + let (ec, _) = h.deploy(input.clone(), DEPLOYER); + assert_eq!(ec, ExitCode::Ok); + + let metadata = h.sdk.contract_metadata(); + let expected = InitialSettings::decode_with_prefix(&input) + .unwrap() + .encode_with_prefix(); + assert_eq!(metadata, expected, "{label}: metadata must be canonical"); + assert!( + metadata.len() <= INITIAL_SETTINGS_V2_SIZE, + "{label}: metadata must stay within the V2 bound" + ); + } + } + + #[test] + fn legacy_metadata_is_stored_in_its_compact_v1_form() { + let input = canonical_legacy(); + let mut h = Harness::new(TOKEN); + let (ec, _) = h.deploy(input.clone(), DEPLOYER); + assert_eq!(ec, ExitCode::Ok); + + // The legacy payload is over ten times the size of what it means; only the meaning is kept. + assert_eq!(input.len(), INITIAL_SETTINGS_LEGACY_SIZE); + assert_eq!(h.sdk.contract_metadata().len(), INITIAL_SETTINGS_V1_SIZE); + + // The token still behaves as the legacy payload described it. + let (ec, out) = h.call(with_sig(SIG_ERC20_NAME, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_string(&out), "Legacy"); + let (ec, out) = h.call(with_sig(SIG_ERC20_DECIMALS, &[])); + assert_eq!(ec, ExitCode::Ok); + assert_eq!(abi_decode_u256_word(&out), U256::from(8u64)); + } + + #[test] + fn creation_charges_code_deposit_gas_for_metadata() { + let input = canonical_v1(); + let mut h = Harness::new(TOKEN); + let before = h.sdk.fuel(); + let (ec, _) = h.deploy(input, DEPLOYER); + assert_eq!(ec, ExitCode::Ok); + + let spent_gas = (before - h.sdk.fuel()) / FUEL_DENOM_RATE; + let metadata_len = h.sdk.contract_metadata().len() as u64; + assert_eq!( + spent_gas, + metadata_len * 200, + "metadata must be charged at the EVM code-deposit rate" + ); + } + + #[test] + fn creation_without_gas_for_metadata_is_rejected() { + let input = canonical_v1(); + let metadata_gas = INITIAL_SETTINGS_V1_SIZE as u64 * 200; + + let mut h = Harness::new_with_gas_limit(TOKEN, metadata_gas - 1); + let (ec, _) = h.deploy(input, DEPLOYER); + assert_eq!(ec, ExitCode::OutOfFuel); + assert!(h.sdk.contract_metadata().is_empty()); + } +} diff --git a/contracts/webauthn/README.md b/contracts/webauthn/README.md index 50935a714..055ffaf6e 100644 --- a/contracts/webauthn/README.md +++ b/contracts/webauthn/README.md @@ -32,10 +32,10 @@ The contract exposes two entrypoint selectors. The legacy selector is `0x94516dd keccak256("verify(bytes,bool,(bytes,bytes,uint256,uint256,bytes32,bytes32),uint256,uint256)") ``` -The strict selector is `0xd6b45308`, derived from: +The strict selector is `0x42520fdd`, derived from: ``` -keccak256("verifyStrict(bytes,bool,bytes32,bytes,uint256,(bytes,bytes,uint256,uint256,bytes32,bytes32),uint256,uint256)") +keccak256("verifyStrict(bytes,bool,bytes32,bytes,(bytes,bytes,uint256,uint256,bytes32,bytes32),uint256,uint256)") ``` ### Legacy Input Parameters @@ -61,11 +61,10 @@ The strict selector takes the following parameters: 1. `challenge` (bytes): The original challenge sent to the authenticator 2. `require_user_verification` (bool): Whether to require the User Verified (UV) flag 3. `expected_rp_id_hash` (bytes32): SHA-256 hash of the expected RP ID, compared with the first 32 bytes of `authenticator_data` -4. `expected_origin` (bytes): Expected origin bytes, compared with `client_data_json` at `origin_index` -5. `origin_index` (uint256): Start index of `"origin":"..."` in `client_data_json` -6. `auth` (WebAuthnAuth struct): The WebAuthn authentication data -7. `x` (uint256): The x coordinate of the public key -8. `y` (uint256): The y coordinate of the public key +4. `expected_origin` (bytes): Expected origin, compared with the decoded `origin` member of `client_data_json` +5. `auth` (WebAuthnAuth struct): The WebAuthn authentication data. The strict selector parses `client_data_json` and ignores `challenge_index` and `type_index` +6. `x` (uint256): The x coordinate of the public key +7. `y` (uint256): The y coordinate of the public key ### Return Value @@ -79,26 +78,45 @@ The contract returns a 32-byte value: The contract performs the following verification steps: 1. **Client Data Verification**: - - Verifies the type is "webauthn.get" - - Confirms the challenge matches the expected value + - The legacy selector verifies the type is "webauthn.get" and the challenge at the supplied indexes + - The strict selector parses `client_data_json` and compares the decoded `type`, `challenge`, and `origin` members 2. **Authenticator Data Validation**: - The strict selector verifies the RP ID hash matches `expected_rp_id_hash` - Checks the User Present (UP) flag is set - Verifies the User Verified (UV) flag if required - Validates backup state consistency - - The strict selector verifies the origin matches `expected_origin` at `origin_index` 3. **Signature Verification**: - Computes the message hash: SHA-256(authenticator_data || SHA-256(client_data_json)) - Verifies the signature using the secp256r1 precompile +### Strict Client Data Profile + +The strict selector never interprets caller-supplied offsets into `client_data_json`, because a +signed object can contain duplicate or decoy `type`, `challenge`, and `origin` members that make a +selected byte range look correct while the JSON means something else. Instead it parses the object +under a deterministic profile and compares decoded values: + +- Input must be exactly one well-formed JSON object of at most 2048 bytes, valid UTF-8, nested at + most 8 levels deep. Trailing content after the object is rejected. +- Duplicate member names are rejected in every object, so `type`, `challenge`, and `origin` each + appear exactly once and each must be a string. +- String escapes, including `\uXXXX` and surrogate pairs, are decoded before comparison; lone + surrogates, invalid escapes, and raw control characters are rejected. +- Unknown members are allowed, as the specification requires, but they are fully parsed and count + against the size and depth limits. +- The decoded `challenge` must equal the canonical base64url encoding of the expected challenge, + which rejects padded or non-URL-safe encodings. +- `crossOrigin`, when present, must be a boolean, and a cross-origin assertion is rejected because + the policy names a single expected origin. + ## Standards Compliance and Caller Policy The W3C assertion verification procedure includes checks that require Relying Party state and policy. This contract verifies: -- `clientDataJSON.type == "webauthn.get"` at the supplied `type_index` -- the expected challenge at the supplied `challenge_index` +- `clientDataJSON.type == "webauthn.get"`, at the supplied `type_index` for the legacy selector and from the parsed object for the strict selector +- the expected challenge, at the supplied `challenge_index` for the legacy selector and from the parsed object for the strict selector - User Present (UP), optional User Verified (UV), and backup-state flag consistency - the P-256 signature over `authenticator_data || SHA-256(client_data_json)` @@ -109,7 +127,7 @@ The caller is still responsible for enforcing: - Challenge freshness and single use. A valid old assertion must not be replayable. - Signature counter policy, if the application relies on clone detection. - Credential ID lookup, allow-list policy, and account ownership. -- Client extension outputs, token binding, and cross-origin policy if these are relevant to the application. +- Client extension outputs and token binding, and cross-origin policy when using the legacy selector. The strict selector rejects assertions marked `crossOrigin`. The `client_data_json`, `authenticator_data`, indexes, and public key are user-supplied inputs. They must not be treated as trusted application data merely because this contract returns `true`; the application must compare policy-critical fields against values it controls. @@ -122,7 +140,7 @@ This implementation deliberately omits some full Relying Party validations: - Signature counter - Attestation objects -The legacy selector should be treated as a cryptographic assertion primitive. Prefer the strict selector when the caller wants the contract to reject RP ID hash or origin mismatches before returning signature success. +The legacy selector matches `type` and `challenge` as substrings at caller-supplied indexes and does not parse `client_data_json`, so a signed object with duplicate or decoy members can satisfy the selected bytes while meaning something else. It should be treated as a cryptographic assertion primitive, with the calling application enforcing client data policy itself. Prefer the strict selector when the caller wants the contract to enforce client data semantics, RP ID hash, and origin before returning signature success. ## License diff --git a/contracts/webauthn/src/client_data.rs b/contracts/webauthn/src/client_data.rs new file mode 100644 index 000000000..60481cda0 --- /dev/null +++ b/contracts/webauthn/src/client_data.rs @@ -0,0 +1,604 @@ +//! Strict, deterministic parser for the WebAuthn `clientDataJSON` object. +//! +//! The strict entrypoint must decide policy on JSON *semantics*, not on byte ranges chosen by the +//! caller: a signed object may carry duplicate or decoy `type`, `challenge`, and `origin` members +//! that make a selected slice look correct while the object means something else. This parser +//! accepts a single well-formed JSON object, rejects duplicate member names, decodes string +//! escapes, and returns the decoded members so callers compare values instead of slices. +//! +//! The accepted profile is a subset of RFC 8259 with fixed size and depth limits, so the work done +//! for a given input is bounded and independent of caller-supplied offsets. + +use alloc::{string::String, vec::Vec}; + +/// Maximum accepted `clientDataJSON` size, in bytes. +/// +/// Conforming clients emit a few hundred bytes; the limit leaves room for long origins and client +/// specific members while bounding parsing work. +pub const MAX_CLIENT_DATA_LEN: usize = 2048; + +/// Maximum accepted JSON nesting depth. The top-level object itself counts as depth 1. +pub const MAX_CLIENT_DATA_DEPTH: usize = 8; + +/// The only `type` value accepted for an authentication assertion. +pub const CLIENT_DATA_TYPE_GET: &str = "webauthn.get"; + +/// Reason a `clientDataJSON` object was rejected by the strict profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientDataError { + /// Input is larger than [`MAX_CLIENT_DATA_LEN`]. + TooLarge, + /// Input is not valid UTF-8. + NotUtf8, + /// Input is not exactly one well-formed JSON object. + Malformed, + /// Nesting is deeper than [`MAX_CLIENT_DATA_DEPTH`]. + TooDeep, + /// The same member name appears more than once in one object. + DuplicateMember, + /// A required member is missing. + MissingMember, + /// A known member has the wrong JSON type. + WrongType, +} + +/// The `clientDataJSON` members interpreted by the strict profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientData { + /// Decoded `type` member. + pub ty: String, + /// Decoded `challenge` member, still base64url encoded as the client wrote it. + pub challenge: String, + /// Decoded `origin` member. + pub origin: String, + /// Decoded `crossOrigin` member; `false` when the member is absent. + pub cross_origin: bool, +} + +/// Parses `clientDataJSON` under the strict profile. +/// +/// Unknown members are allowed, as the specification requires, but they are fully parsed and count +/// against the size and depth limits. Duplicate member names are rejected in every object, so each +/// of `type`, `challenge`, and `origin` appears exactly once when this returns. +pub fn parse_client_data(input: &[u8]) -> Result { + if input.len() > MAX_CLIENT_DATA_LEN { + return Err(ClientDataError::TooLarge); + } + core::str::from_utf8(input).map_err(|_| ClientDataError::NotUtf8)?; + + let mut parser = Parser { input, pos: 0 }; + parser.skip_whitespace(); + let members = parser.parse_object(1)?; + parser.skip_whitespace(); + if parser.pos != input.len() { + return Err(ClientDataError::Malformed); + } + + let mut ty = None; + let mut challenge = None; + let mut origin = None; + let mut cross_origin = false; + + for (name, value) in members { + match name.as_str() { + "type" => ty = Some(into_string(value)?), + "challenge" => challenge = Some(into_string(value)?), + "origin" => origin = Some(into_string(value)?), + "crossOrigin" => { + cross_origin = match value { + Value::Bool(flag) => flag, + _ => return Err(ClientDataError::WrongType), + } + } + _ => {} + } + } + + Ok(ClientData { + ty: ty.ok_or(ClientDataError::MissingMember)?, + challenge: challenge.ok_or(ClientDataError::MissingMember)?, + origin: origin.ok_or(ClientDataError::MissingMember)?, + cross_origin, + }) +} + +/// A parsed JSON value. Contents of nested objects and arrays are validated but not retained, +/// because the strict profile never compares them. +enum Value { + Str(String), + Bool(bool), + Null, + Number, + Array, + Object, +} + +fn into_string(value: Value) -> Result { + match value { + Value::Str(string) => Ok(string), + _ => Err(ClientDataError::WrongType), + } +} + +struct Parser<'a> { + input: &'a [u8], + pos: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option { + self.input.get(self.pos).copied() + } + + fn bump(&mut self) -> Option { + let byte = self.peek()?; + self.pos += 1; + Some(byte) + } + + fn expect(&mut self, byte: u8) -> Result<(), ClientDataError> { + if self.bump() == Some(byte) { + Ok(()) + } else { + Err(ClientDataError::Malformed) + } + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.pos += 1; + } + } + + fn parse_object(&mut self, depth: usize) -> Result, ClientDataError> { + if depth > MAX_CLIENT_DATA_DEPTH { + return Err(ClientDataError::TooDeep); + } + self.expect(b'{')?; + + let mut members: Vec<(String, Value)> = Vec::new(); + self.skip_whitespace(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(members); + } + + loop { + self.skip_whitespace(); + let name = self.parse_string()?; + if members.iter().any(|(existing, _)| *existing == name) { + return Err(ClientDataError::DuplicateMember); + } + self.skip_whitespace(); + self.expect(b':')?; + self.skip_whitespace(); + let value = self.parse_value(depth)?; + members.push((name, value)); + + self.skip_whitespace(); + match self.bump() { + Some(b',') => continue, + Some(b'}') => return Ok(members), + _ => return Err(ClientDataError::Malformed), + } + } + } + + fn parse_array(&mut self, depth: usize) -> Result<(), ClientDataError> { + if depth > MAX_CLIENT_DATA_DEPTH { + return Err(ClientDataError::TooDeep); + } + self.expect(b'[')?; + + self.skip_whitespace(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(()); + } + + loop { + self.skip_whitespace(); + self.parse_value(depth)?; + self.skip_whitespace(); + match self.bump() { + Some(b',') => continue, + Some(b']') => return Ok(()), + _ => return Err(ClientDataError::Malformed), + } + } + } + + fn parse_value(&mut self, depth: usize) -> Result { + match self.peek().ok_or(ClientDataError::Malformed)? { + b'"' => Ok(Value::Str(self.parse_string()?)), + b'{' => { + self.parse_object(depth + 1)?; + Ok(Value::Object) + } + b'[' => { + self.parse_array(depth + 1)?; + Ok(Value::Array) + } + b't' => { + self.expect_literal(b"true")?; + Ok(Value::Bool(true)) + } + b'f' => { + self.expect_literal(b"false")?; + Ok(Value::Bool(false)) + } + b'n' => { + self.expect_literal(b"null")?; + Ok(Value::Null) + } + b'-' | b'0'..=b'9' => { + self.parse_number()?; + Ok(Value::Number) + } + _ => Err(ClientDataError::Malformed), + } + } + + fn expect_literal(&mut self, literal: &[u8]) -> Result<(), ClientDataError> { + for byte in literal { + self.expect(*byte)?; + } + Ok(()) + } + + fn parse_string(&mut self) -> Result { + self.expect(b'"')?; + + let mut decoded: Vec = Vec::new(); + loop { + match self.bump().ok_or(ClientDataError::Malformed)? { + b'"' => { + // The whole input was checked as UTF-8 and strings are only split at ASCII + // delimiters, so the decoded bytes are valid UTF-8. + return String::from_utf8(decoded).map_err(|_| ClientDataError::NotUtf8); + } + b'\\' => { + let escape = self.bump().ok_or(ClientDataError::Malformed)?; + let unescaped = match escape { + b'"' => '"', + b'\\' => '\\', + b'/' => '/', + b'b' => '\u{8}', + b'f' => '\u{c}', + b'n' => '\n', + b'r' => '\r', + b't' => '\t', + b'u' => self.parse_unicode_escape()?, + _ => return Err(ClientDataError::Malformed), + }; + let mut buffer = [0u8; 4]; + decoded.extend_from_slice(unescaped.encode_utf8(&mut buffer).as_bytes()); + } + byte if byte < 0x20 => return Err(ClientDataError::Malformed), + byte => decoded.push(byte), + } + } + } + + fn parse_unicode_escape(&mut self) -> Result { + let first = self.parse_hex4()?; + match first { + // A high surrogate is only valid when a low surrogate escape follows. + 0xD800..=0xDBFF => { + self.expect(b'\\')?; + self.expect(b'u')?; + let second = self.parse_hex4()?; + if !(0xDC00..=0xDFFF).contains(&second) { + return Err(ClientDataError::Malformed); + } + let code = + 0x10000 + ((u32::from(first - 0xD800) << 10) | u32::from(second - 0xDC00)); + char::from_u32(code).ok_or(ClientDataError::Malformed) + } + 0xDC00..=0xDFFF => Err(ClientDataError::Malformed), + _ => char::from_u32(u32::from(first)).ok_or(ClientDataError::Malformed), + } + } + + fn parse_hex4(&mut self) -> Result { + let mut value = 0u16; + for _ in 0..4 { + let byte = self.bump().ok_or(ClientDataError::Malformed)?; + let digit = match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => return Err(ClientDataError::Malformed), + }; + value = (value << 4) | u16::from(digit); + } + Ok(value) + } + + fn parse_number(&mut self) -> Result<(), ClientDataError> { + if self.peek() == Some(b'-') { + self.pos += 1; + } + + // A leading zero may not be followed by more digits, so `01` is rejected. + match self.bump().ok_or(ClientDataError::Malformed)? { + b'0' => {} + b'1'..=b'9' => self.skip_digits(), + _ => return Err(ClientDataError::Malformed), + } + + if self.peek() == Some(b'.') { + self.pos += 1; + self.expect_digit()?; + self.skip_digits(); + } + + if matches!(self.peek(), Some(b'e' | b'E')) { + self.pos += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.pos += 1; + } + self.expect_digit()?; + self.skip_digits(); + } + + Ok(()) + } + + fn expect_digit(&mut self) -> Result<(), ClientDataError> { + match self.peek() { + Some(b'0'..=b'9') => { + self.pos += 1; + Ok(()) + } + _ => Err(ClientDataError::Malformed), + } + } + + fn skip_digits(&mut self) { + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{format, string::ToString, vec}; + + const ORIGIN: &str = "http://localhost:3005"; + const CHALLENGE: &str = "9jEFijuhEWrM4SOW-tChJbUEHEP44Vcjs"; + + fn canonical() -> String { + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{CHALLENGE}\",\"origin\":\"{ORIGIN}\"}}" + ) + } + + fn parse(json: &str) -> Result { + parse_client_data(json.as_bytes()) + } + + #[test] + fn canonical_object_is_accepted() { + let data = parse(&canonical()).unwrap(); + + assert_eq!(data.ty, CLIENT_DATA_TYPE_GET); + assert_eq!(data.challenge, CHALLENGE); + assert_eq!(data.origin, ORIGIN); + assert!(!data.cross_origin); + } + + #[test] + fn conforming_client_vectors_are_accepted() { + // Chrome appends `crossOrigin` and a free-form member; Safari emits the members alone. + let chrome = format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{CHALLENGE}\",\"origin\":\"{ORIGIN}\",\ + \"crossOrigin\":false,\"other_keys_can_be_added_here\":\"do not compare clientDataJSON \ + against a template. See https://goo.gl/yabPex\"}}" + ); + let with_whitespace = format!( + "{{ \"type\" : \"webauthn.get\" ,\n\t\"challenge\" : \"{CHALLENGE}\" , \ + \"origin\" : \"{ORIGIN}\" }}" + ); + // Member order carries no meaning in JSON, so a reordered object must still be accepted. + let reordered = format!( + "{{\"origin\":\"{ORIGIN}\",\"challenge\":\"{CHALLENGE}\",\"type\":\"webauthn.get\"}}" + ); + + for json in [chrome, with_whitespace, reordered] { + let data = parse(&json).unwrap(); + assert_eq!(data.ty, CLIENT_DATA_TYPE_GET); + assert_eq!(data.challenge, CHALLENGE); + assert_eq!(data.origin, ORIGIN); + assert!(!data.cross_origin); + } + } + + #[test] + fn duplicate_members_are_rejected() { + let duplicates = [ + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{CHALLENGE}\",\ + \"origin\":\"{ORIGIN}\",\"origin\":\"https://evil.example\"}}" + ), + format!( + "{{\"origin\":\"https://evil.example\",\"type\":\"webauthn.get\",\ + \"challenge\":\"{CHALLENGE}\",\"origin\":\"{ORIGIN}\"}}" + ), + format!( + "{{\"type\":\"webauthn.create\",\"type\":\"webauthn.get\",\ + \"challenge\":\"{CHALLENGE}\",\"origin\":\"{ORIGIN}\"}}" + ), + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"decoy\",\ + \"challenge\":\"{CHALLENGE}\",\"origin\":\"{ORIGIN}\"}}" + ), + // An escaped member name decodes to the same name, so it is a duplicate too. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{CHALLENGE}\",\ + \"origin\":\"{ORIGIN}\",\"\\u006frigin\":\"https://evil.example\"}}" + ), + ]; + + for json in duplicates { + assert_eq!( + parse(&json), + Err(ClientDataError::DuplicateMember), + "{json}" + ); + } + } + + #[test] + fn decoy_members_do_not_change_the_decoded_values() { + // The decoys live inside an unknown member's string value and inside a nested object, both + // of which the old index-based check could be pointed at. + let json = format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{CHALLENGE}\",\"origin\":\"{ORIGIN}\",\ + \"decoy\":\"\\\"origin\\\":\\\"https://evil.example\\\"\",\ + \"nested\":{{\"origin\":\"https://evil.example\",\"type\":\"webauthn.create\"}}}}" + ); + + let data = parse(&json).unwrap(); + + assert_eq!(data.ty, CLIENT_DATA_TYPE_GET); + assert_eq!(data.origin, ORIGIN); + } + + #[test] + fn escaped_values_are_decoded_before_comparison() { + let json = format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{CHALLENGE}\",\ + \"origin\":\"http:\\/\\/localhost:\\u0033005\"}}" + ); + + assert_eq!(parse(&json).unwrap().origin, ORIGIN); + + // Surrogate pairs decode to a single scalar value. + let json = format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{CHALLENGE}\",\ + \"origin\":\"https://\\ud83d\\ude00.example\"}}" + ); + + assert_eq!(parse(&json).unwrap().origin, "https://\u{1f600}.example"); + } + + #[test] + fn malformed_objects_are_rejected() { + let malformed = [ + // Not a single top-level object. + "".to_string(), + "\"webauthn.get\"".to_string(), + format!("[{}]", canonical()), + format!("{} ", canonical()).replace('}', ""), + // Trailing content after the object, where a second decoy object could hide. + format!("{}{}", canonical(), canonical()), + format!("{}!", canonical()), + // Structural errors. + canonical().replace("\"origin\"", "origin"), + canonical().replace("\"type\":", "\"type\"="), + canonical().replace("}", ",}"), + format!("{{,{}", &canonical()[1..]), + // Unterminated string and a raw control character inside one. + canonical().replace("localhost:3005\"}", "localhost:3005}"), + canonical().replace("http://", "http\n://"), + // Invalid escapes. + canonical().replace("http://", "\\x"), + canonical().replace("http", "\\u00"), + canonical().replace("http", "\\uZZZZ"), + // Lone surrogates. + canonical().replace("http", "\\ud83d"), + canonical().replace("http", "\\ude00"), + // Non-canonical numbers. + canonical().replace("}", ",\"count\":01}"), + canonical().replace("}", ",\"count\":1.}"), + canonical().replace("}", ",\"count\":1e}"), + canonical().replace("}", ",\"count\":+1}"), + // Javascript literals that are not JSON. + canonical().replace("}", ",\"count\":NaN}"), + canonical().replace("}", ",\"crossOrigin\":False}"), + ]; + + for json in malformed { + assert!( + matches!( + parse(&json), + Err(ClientDataError::Malformed | ClientDataError::TooDeep) + ), + "expected rejection of {json}" + ); + } + } + + #[test] + fn missing_or_mistyped_members_are_rejected() { + assert_eq!( + parse(&canonical().replace("\"origin\"", "\"Origin\"")), + Err(ClientDataError::MissingMember) + ); + assert_eq!( + parse(&canonical().replace("\"webauthn.get\"", "null")), + Err(ClientDataError::WrongType) + ); + assert_eq!( + parse(&canonical().replace(&format!("\"{CHALLENGE}\""), "1234")), + Err(ClientDataError::WrongType) + ); + assert_eq!( + parse(&canonical().replace("}", ",\"crossOrigin\":\"false\"}")), + Err(ClientDataError::WrongType) + ); + assert_eq!(parse("{}"), Err(ClientDataError::MissingMember)); + } + + #[test] + fn cross_origin_member_is_decoded() { + assert!( + parse(&canonical().replace("}", ",\"crossOrigin\":true}")) + .unwrap() + .cross_origin + ); + assert!( + !parse(&canonical().replace("}", ",\"crossOrigin\":false}")) + .unwrap() + .cross_origin + ); + } + + #[test] + fn size_and_depth_limits_are_enforced() { + let padding = "a".repeat(MAX_CLIENT_DATA_LEN); + let oversized = canonical().replace("}", &format!(",\"pad\":\"{padding}\"}}")); + assert_eq!(parse(&oversized), Err(ClientDataError::TooLarge)); + + let at_limit = format!( + "{}{}", + canonical(), + " ".repeat(MAX_CLIENT_DATA_LEN - canonical().len()) + ); + assert_eq!(at_limit.len(), MAX_CLIENT_DATA_LEN); + assert!(parse(&at_limit).is_ok()); + + // The top-level object is depth 1, so MAX_CLIENT_DATA_DEPTH - 1 nested arrays fit. + let nesting = |levels: usize| { + let value = format!("{}{}", "[".repeat(levels), "]".repeat(levels)); + canonical().replace("}", &format!(",\"nested\":{value}}}")) + }; + assert!(parse(&nesting(MAX_CLIENT_DATA_DEPTH - 1)).is_ok()); + assert_eq!( + parse(&nesting(MAX_CLIENT_DATA_DEPTH)), + Err(ClientDataError::TooDeep) + ); + } + + #[test] + fn invalid_utf8_is_rejected() { + let mut bytes = canonical().into_bytes(); + let position = bytes.iter().position(|byte| *byte == b'h').unwrap(); + bytes.splice(position..position, vec![0xff]); + + assert_eq!(parse_client_data(&bytes), Err(ClientDataError::NotUtf8)); + } +} diff --git a/contracts/webauthn/src/lib.rs b/contracts/webauthn/src/lib.rs index d20e922cf..f40532c6b 100644 --- a/contracts/webauthn/src/lib.rs +++ b/contracts/webauthn/src/lib.rs @@ -2,6 +2,7 @@ extern crate alloc; extern crate fluentbase_sdk; +mod client_data; mod webauthn; use fluentbase_sdk::{ @@ -14,10 +15,10 @@ use webauthn::{verify_webauthn, verify_webauthn_with_policy, WebAuthnAuth, WebAu /// keccak256("verify(bytes,bool,(bytes,bytes,uint256,uint256,bytes32,bytes32),uint256,uint256)") const VERIFY_SELECTOR: [u8; 4] = [0x94, 0x51, 0x6d, 0xde]; -/// Function selector: 0xd6b45308 +/// Function selector: 0x42520fdd /// Derived from: -/// keccak256("verifyStrict(bytes,bool,bytes32,bytes,uint256,(bytes,bytes,uint256,uint256,bytes32,bytes32),uint256,uint256)") -const VERIFY_STRICT_SELECTOR: [u8; 4] = [0xd6, 0xb4, 0x53, 0x08]; +/// keccak256("verifyStrict(bytes,bool,bytes32,bytes,(bytes,bytes,uint256,uint256,bytes32,bytes32),uint256,uint256)") +const VERIFY_STRICT_SELECTOR: [u8; 4] = [0x42, 0x52, 0x0f, 0xdd]; /// Estimated verification cost, in EVM gas units. const WEBAUTHN_VERIFY_GAS: u64 = 22_000; @@ -58,14 +59,11 @@ pub fn main_entry(sdk: &mut SDK) -> Result<(), ExitCode> { require_user_verification, expected_rp_id_hash, expected_origin, - origin_index, auth, x, y, - ) = SolidityABI::<(Bytes, bool, B256, Bytes, U256, WebAuthnAuth, U256, U256)>::decode( - ¶ms, 0, - ) - .map_err(|_| ExitCode::MalformedBuiltinParams)?; + ) = SolidityABI::<(Bytes, bool, B256, Bytes, WebAuthnAuth, U256, U256)>::decode(¶ms, 0) + .map_err(|_| ExitCode::MalformedBuiltinParams)?; verify_webauthn_with_policy( &challenge, @@ -73,7 +71,6 @@ pub fn main_entry(sdk: &mut SDK) -> Result<(), ExitCode> { &WebAuthnPolicy { expected_rp_id_hash, expected_origin, - origin_index, }, &auth, x, @@ -104,7 +101,7 @@ mod tests { elliptic_curve::rand_core::OsRng, }; - type StrictCallParams = (Bytes, bool, B256, Bytes, U256, WebAuthnAuth, U256, U256); + type StrictCallParams = (Bytes, bool, B256, Bytes, WebAuthnAuth, U256, U256); fn valid_call_params( require_user_verification: bool, @@ -113,12 +110,6 @@ mod tests { &hex::decode("f631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf") .unwrap(), ); - let authenticator_data = Bytes::copy_from_slice( - &hex::decode( - "49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000101", - ) - .unwrap(), - ); let client_data_json = Bytes::copy_from_slice( format!( "{{\"type\":\"webauthn.get\",\"challenge\":\"{}\",\"origin\":\"http://localhost:3005\"}}", @@ -127,6 +118,23 @@ mod tests { .as_bytes(), ); + signed_call_params(challenge, require_user_verification, client_data_json) + } + + /// Builds call parameters with a fresh key pair signing the supplied client data, so the + /// assertion is cryptographically valid and only the client data policy is under test. + fn signed_call_params( + challenge: Bytes, + require_user_verification: bool, + client_data_json: Bytes, + ) -> (Bytes, bool, WebAuthnAuth, U256, U256) { + let authenticator_data = Bytes::copy_from_slice( + &hex::decode( + "49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000101", + ) + .unwrap(), + ); + let mut signing_key = SigningKey::random(&mut OsRng); let verifying_key = VerifyingKey::from(&signing_key); let public_key = verifying_key.to_encoded_point(false); @@ -172,7 +180,7 @@ mod tests { fn encode_strict_call(params_tuple: &StrictCallParams) -> Vec { let mut params = BytesMut::new(); - SolidityABI::<(Bytes, bool, B256, Bytes, U256, WebAuthnAuth, U256, U256)>::encode( + SolidityABI::<(Bytes, bool, B256, Bytes, WebAuthnAuth, U256, U256)>::encode( params_tuple, &mut params, 0, @@ -184,31 +192,44 @@ mod tests { input } - fn valid_strict_call_input(require_user_verification: bool) -> (Vec, StrictCallParams) { - let (challenge, require_user_verification, auth, x, y) = - valid_call_params(require_user_verification); + fn strict_call_params(params: (Bytes, bool, WebAuthnAuth, U256, U256)) -> StrictCallParams { + let (challenge, require_user_verification, auth, x, y) = params; let expected_rp_id_hash = B256::from_slice(&auth.authenticator_data[..32]); let expected_origin = Bytes::copy_from_slice(b"http://localhost:3005"); - let origin_index = U256::from( - auth.client_data_json - .windows(b"\"origin\"".len()) - .position(|window| window == b"\"origin\"") - .unwrap(), - ); - let params = ( + + ( challenge, require_user_verification, expected_rp_id_hash, expected_origin, - origin_index, auth, x, y, - ); + ) + } + + fn valid_strict_call_input(require_user_verification: bool) -> (Vec, StrictCallParams) { + let params = strict_call_params(valid_call_params(require_user_verification)); (encode_strict_call(¶ms), params) } + /// Signs `client_data_json` and runs it through the strict entrypoint with a matching policy. + fn strict_result_for_client_data(client_data_json: &str) -> Vec { + let challenge = Bytes::copy_from_slice( + &hex::decode("f631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf") + .unwrap(), + ); + let params = strict_call_params(signed_call_params( + challenge, + true, + Bytes::copy_from_slice(client_data_json.as_bytes()), + )); + + let (output, _) = exec(&encode_strict_call(¶ms), WEBAUTHN_VERIFY_GAS).unwrap(); + output + } + fn valid_call_input(require_user_verification: bool) -> Vec { encode_call(&valid_call_params(require_user_verification)) } @@ -260,6 +281,92 @@ mod tests { assert_eq!(fuel, WEBAUTHN_VERIFY_GAS * FUEL_DENOM_RATE); } + #[test] + fn strict_accepts_conforming_client_data_variants() { + let challenge = webauthn::base64url_encode( + &hex::decode("f631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf") + .unwrap(), + ); + let variants = [ + // Chrome-style object with the extra members conforming clients append. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\",\ + \"origin\":\"http://localhost:3005\",\"crossOrigin\":false,\ + \"other_keys_can_be_added_here\":\"do not compare clientDataJSON against a \ + template. See https://goo.gl/yabPex\"}}" + ), + // Members in a different order, which the index-based check could not accept. + format!( + "{{\"origin\":\"http://localhost:3005\",\"challenge\":\"{challenge}\",\ + \"type\":\"webauthn.get\"}}" + ), + // Escaped solidus in the origin, which decodes to the expected origin. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\",\ + \"origin\":\"http:\\/\\/localhost:3005\"}}" + ), + ]; + + for client_data_json in variants { + assert_eq!( + strict_result_for_client_data(&client_data_json), + B256::with_last_byte(1)[..], + "expected {client_data_json} to verify" + ); + } + } + + #[test] + fn strict_rejects_ambiguous_or_malformed_client_data() { + let challenge = webauthn::base64url_encode( + &hex::decode("f631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf") + .unwrap(), + ); + let rejected = [ + // Duplicate origin: the expected origin is present, but the object is ambiguous. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\",\ + \"origin\":\"http://localhost:3005\",\"origin\":\"https://evil.example\"}}" + ), + // Duplicate type, where only the decoy is an assertion type. + format!( + "{{\"type\":\"webauthn.get\",\"type\":\"webauthn.create\",\ + \"challenge\":\"{challenge}\",\"origin\":\"http://localhost:3005\"}}" + ), + // A decoy origin hidden in an unknown member's string value. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\",\ + \"decoy\":\"\\\"origin\\\":\\\"http://localhost:3005\\\"\",\ + \"origin\":\"https://evil.example\"}}" + ), + // Cross-origin assertion, which the single-origin policy does not allow. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\",\ + \"origin\":\"http://localhost:3005\",\"crossOrigin\":true}}" + ), + // Padded challenge encoding instead of the canonical base64url form. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}=\",\ + \"origin\":\"http://localhost:3005\"}}" + ), + // Trailing object after the client data. + format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\",\ + \"origin\":\"http://localhost:3005\"}}{{\"origin\":\"https://evil.example\"}}" + ), + // Missing origin altogether. + format!("{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\"}}"), + ]; + + for client_data_json in rejected { + assert_eq!( + strict_result_for_client_data(&client_data_json), + B256::default()[..], + "expected {client_data_json} to be rejected" + ); + } + } + #[test] fn missing_user_verification_returns_false_when_required() { let mut params = valid_call_params(true); diff --git a/contracts/webauthn/src/webauthn.rs b/contracts/webauthn/src/webauthn.rs index 1d5be79c3..5a8269901 100644 --- a/contracts/webauthn/src/webauthn.rs +++ b/contracts/webauthn/src/webauthn.rs @@ -1,3 +1,4 @@ +use crate::client_data::{parse_client_data, CLIENT_DATA_TYPE_GET}; use alloc::{format, string::String, vec::Vec}; use fluentbase_sdk::{codec::Codec, crypto::crypto_sha256, Bytes, ExitCode, B256, U256}; @@ -30,11 +31,13 @@ pub struct WebAuthnAuth { pub client_data_json: Bytes, /// Start index of "challenge":"..." in `client_data_json`. - /// Used to verify that the client data contains the correct challenge. + /// Used by the legacy entrypoint to verify that the client data contains the correct + /// challenge. The strict entrypoint parses `client_data_json` and ignores this index. pub challenge_index: U256, /// Start index of "type":"..." in `client_data_json`. - /// Used to verify that the client data has the correct type (webauthn.get). + /// Used by the legacy entrypoint to verify that the client data has the correct type + /// (webauthn.get). The strict entrypoint parses `client_data_json` and ignores this index. pub type_index: U256, /// Signature components (r, s) of the WebAuthn authentication assertion. @@ -46,7 +49,6 @@ pub struct WebAuthnAuth { pub struct WebAuthnPolicy { pub expected_rp_id_hash: B256, pub expected_origin: Bytes, - pub origin_index: U256, } /// Verifies a WebAuthn Authentication Assertion @@ -83,19 +85,18 @@ pub fn verify_webauthn( return Ok(false); } - // Step 2: Verify authenticator data flags - if !verify_authenticator_flags(&auth.authenticator_data, require_user_verification) { - return Ok(false); - } - - // Step 3: Compute message hash - let message_hash = compute_message_hash(&auth.authenticator_data, &auth.client_data_json[..]); - - // Step 4: Verify signature - verify_signature(message_hash, auth.r, auth.s, x, y, gas_limit) + // Step 2: Verify authenticator flags and the signature + verify_flags_and_signature(auth, require_user_verification, x, y, gas_limit) } -/// Verifies a WebAuthn assertion and caller-controlled relying-party policy. +/// Verifies a WebAuthn assertion against a caller-controlled relying-party policy. +/// +/// Unlike [`verify_webauthn`], this path never looks at caller-supplied offsets into +/// `client_data_json`. The client data is parsed under the strict profile of +/// [`crate::client_data`] and the decoded `type`, `challenge`, and `origin` values are compared, +/// so signed input carrying duplicate or decoy members is rejected instead of matching a +/// caller-selected slice. Cross-origin assertions are rejected as well, because the policy names a +/// single expected origin. pub fn verify_webauthn_with_policy( challenge: &Bytes, require_user_verification: bool, @@ -109,47 +110,59 @@ pub fn verify_webauthn_with_policy( return Ok(false); } - if !verify_client_data_json_origin( - &auth.client_data_json, - &policy.expected_origin, - policy.origin_index, - ) { + let client_data = match parse_client_data(&auth.client_data_json) { + Ok(client_data) => client_data, + Err(_) => return Ok(false), + }; + + if client_data.ty != CLIENT_DATA_TYPE_GET { return Ok(false); } - verify_webauthn(challenge, require_user_verification, auth, x, y, gas_limit) -} + // Comparing against the canonical encoding also rejects padded or non-URL-safe variants. + if client_data.challenge != base64url_encode(challenge) { + return Ok(false); + } -fn verify_rp_id_hash(authenticator_data: &Bytes, expected_rp_id_hash: &B256) -> bool { - if authenticator_data.len() < 32 { - return false; + if client_data.origin.as_bytes() != policy.expected_origin.as_ref() { + return Ok(false); } - &authenticator_data[..32] == expected_rp_id_hash.as_slice() + if client_data.cross_origin { + return Ok(false); + } + + verify_flags_and_signature(auth, require_user_verification, x, y, gas_limit) } -fn verify_client_data_json_origin( - client_data_json: &Bytes, - expected_origin: &Bytes, - origin_index: U256, -) -> bool { - let origin_idx = match u32::try_from(origin_index) { - Ok(idx) => idx as usize, - Err(_) => return false, - }; +/// Verifies the authenticator data flags and the assertion signature. +fn verify_flags_and_signature( + auth: &WebAuthnAuth, + require_user_verification: bool, + x: U256, + y: U256, + gas_limit: u64, +) -> Result { + if !verify_authenticator_flags(&auth.authenticator_data, require_user_verification) { + return Ok(false); + } + + let message_hash = compute_message_hash(&auth.authenticator_data, &auth.client_data_json[..]); - if origin_idx >= client_data_json.len() { + verify_signature(message_hash, auth.r, auth.s, x, y, gas_limit) +} + +fn verify_rp_id_hash(authenticator_data: &Bytes, expected_rp_id_hash: &B256) -> bool { + if authenticator_data.len() < 32 { return false; } - let mut origin_str = Vec::with_capacity(b"\"origin\":\"\"".len() + expected_origin.len()); - origin_str.extend_from_slice(b"\"origin\":\""); - origin_str.extend_from_slice(expected_origin.as_ref()); - origin_str.extend_from_slice(b"\""); - contains_at(origin_str.as_slice(), client_data_json, origin_idx) + &authenticator_data[..32] == expected_rp_id_hash.as_slice() } -/// Verifies the client data JSON type and challenge +/// Verifies the client data JSON type and challenge at the caller-supplied offsets. +/// +/// Only the legacy entrypoint uses this; the strict entrypoint parses the client data instead. fn verify_client_data_json( client_data_json: &Bytes, challenge: &Bytes, @@ -456,34 +469,6 @@ mod tests { ); } - #[test] - fn test_verify_client_data_json_origin() { - let (client_data_json, _, _, _) = create_valid_client_data_json_test_data(); - let origin = Bytes::copy_from_slice(b"http://localhost:3005"); - let origin_index = U256::from( - client_data_json - .windows(b"\"origin\"".len()) - .position(|window| window == b"\"origin\"") - .unwrap(), - ); - - assert!(verify_client_data_json_origin( - &client_data_json, - &origin, - origin_index, - )); - assert!(!verify_client_data_json_origin( - &client_data_json, - &Bytes::copy_from_slice(b"https://example.com"), - origin_index, - )); - assert!(!verify_client_data_json_origin( - &client_data_json, - &origin, - U256::from(client_data_json.len()), - )); - } - #[test] fn test_verify_rp_id_hash() { let challenge = create_valid_challenge(); @@ -505,12 +490,6 @@ mod tests { let (auth, x, y) = create_valid_webauthn(&challenge); let expected_rp_id_hash = B256::from_slice(&auth.authenticator_data[..32]); let expected_origin = Bytes::copy_from_slice(b"http://localhost:3005"); - let origin_index = U256::from( - auth.client_data_json - .windows(b"\"origin\"".len()) - .position(|window| window == b"\"origin\"") - .unwrap(), - ); assert!(verify_webauthn_with_policy( &challenge, @@ -518,7 +497,6 @@ mod tests { &WebAuthnPolicy { expected_rp_id_hash, expected_origin: expected_origin.clone(), - origin_index, }, &auth, x, @@ -533,7 +511,6 @@ mod tests { &WebAuthnPolicy { expected_rp_id_hash: B256::with_last_byte(1), expected_origin: expected_origin.clone(), - origin_index, }, &auth, x, @@ -548,7 +525,21 @@ mod tests { &WebAuthnPolicy { expected_rp_id_hash, expected_origin: Bytes::copy_from_slice(b"https://example.com"), - origin_index, + }, + &auth, + x, + y, + 100000, + ) + .unwrap()); + + // A different challenge must not verify even though the origin and RP ID hash match. + assert!(!verify_webauthn_with_policy( + &Bytes::copy_from_slice(b"other challenge"), + true, + &WebAuthnPolicy { + expected_rp_id_hash, + expected_origin, }, &auth, x, diff --git a/crates/build/src/build.rs b/crates/build/src/build.rs index 96422a535..4dc573b0e 100644 --- a/crates/build/src/build.rs +++ b/crates/build/src/build.rs @@ -1,5 +1,8 @@ #![allow(clippy::too_many_arguments)] -use crate::{docker, generators, Artifact, BuildArgs, BUILD_TARGET, DEFAULT_DOCKER_TAG}; +use crate::{ + docker::{self, VerifiedImage}, + generators, Artifact, BuildArgs, BUILD_TARGET, DEFAULT_DOCKER_TAG, +}; use anyhow::{Context, Result}; use cargo_metadata::{Metadata, MetadataCommand, Package}; use std::{ @@ -125,10 +128,7 @@ pub fn execute_build(args: &BuildArgs, contract_dir: Option) -> Result< // Determine the Docker image that would be used for all generators let docker_image = if args.docker { - Some(docker::ensure_rust_image(&format!( - "{}:{}", - args.docker_image, args.docker_tag - ))?) + Some(args.ensure_docker_image()?) } else { None }; @@ -193,7 +193,7 @@ fn build_wasm( args: &BuildArgs, contract_dir: &Path, package: &Package, - docker_image: &Option, + docker_image: &Option, mount_dir: &Path, ) -> Result { let target_dir = if let Some(dir) = &args.target_dir { @@ -227,9 +227,7 @@ fn build_wasm( // Run build let env_vars = vec![("CARGO_ENCODED_RUSTFLAGS".to_string(), args.rust_flags())]; - let docker_config = docker_image - .as_ref() - .map(|image| (image.as_str(), mount_dir)); + let docker_config = docker_image.as_ref().map(|image| (image, mount_dir)); let rust_toolchain = args.toolchain_version(contract_dir); eprintln!("Detected toolchain: {:?}", rust_toolchain); @@ -315,7 +313,7 @@ fn find_wasm_artifact(target_dir: &Path, package: &Package) -> Result { fn optimize_wasm( wasm_path: &Path, - docker_config: Option<(&str, &Path)>, + docker_config: Option<(&VerifiedImage, &Path)>, rust_toolchain: &Option, ) -> Result<()> { let work_dir = wasm_path.parent().unwrap(); @@ -347,7 +345,7 @@ fn optimize_wasm( fn run_command>( args: &[S], work_dir: &Path, - docker_config: Option<(&str, &Path)>, + docker_config: Option<(&VerifiedImage, &Path)>, env_vars: &[(String, String)], rust_toolchain: &Option, ) -> Result<()> { @@ -400,7 +398,7 @@ fn generate_artifacts( output_dir: &Path, wasm_path: &Path, package_name: &str, - docker_image: &Option, + docker_image: &Option, mount_dir: &Path, rust_toolchain: &Option, result: &mut BuildResult, @@ -443,9 +441,7 @@ fn generate_artifacts( for artifact in &artifacts { match artifact { Artifact::Wat => { - let docker_config = docker_image - .as_ref() - .map(|image| (image.as_str(), mount_dir)); + let docker_config = docker_image.as_ref().map(|image| (image, mount_dir)); run_command( &["wasm2wat", "lib.wasm", "-o", "lib.wat"], @@ -496,7 +492,7 @@ fn generate_artifacts( args, wasm, rwasm_data.as_deref(), - docker_image.as_deref(), + docker_image.as_ref(), rust_toolchain.as_deref(), )?; @@ -525,7 +521,7 @@ fn generate_artifacts( args, wasm, Some(&rwasm_data), - docker_image.as_deref(), + docker_image.as_ref(), rust_toolchain.as_deref(), )?; diff --git a/crates/build/src/docker.rs b/crates/build/src/docker.rs index ccfd92318..fe251b732 100644 --- a/crates/build/src/docker.rs +++ b/crates/build/src/docker.rs @@ -1,16 +1,130 @@ use crate::{utils::parse_rustc_version, CARGO_CACHE_VOLUME, DOCKER_PLATFORM}; use anyhow::{bail, Context, Result}; -use std::{path::Path, process::Command}; +use std::{fmt, path::Path, process::Command}; + +/// Every digest we accept is a registry manifest digest, which is always sha256. +const DIGEST_PREFIX: &str = "sha256:"; +const DIGEST_HEX_LEN: usize = 64; + +/// A parsed `[registry/]repository[:tag][@sha256:...]` image reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageRef { + /// Repository without tag or digest, e.g. `ghcr.io/fluentlabs-xyz/fluentbase-build`. + pub repository: String, + /// Mutable tag, if the reference carried one. + pub tag: Option, + /// Immutable digest, if the reference was pinned. + pub digest: Option, +} + +impl ImageRef { + pub fn parse(reference: &str) -> Result { + let reference = reference.trim(); + if reference.is_empty() { + bail!("Docker image reference is empty"); + } + + let (name, digest) = match reference.split_once('@') { + Some((name, digest)) => (name, Some(validate_digest(digest)?)), + None => (reference, None), + }; + + // A colon belongs to the tag only when it comes after the last path separator, + // otherwise it is a registry port (e.g. `localhost:5000/fluentbase-build`). + let name_start = name.rfind('/').map(|index| index + 1).unwrap_or(0); + let (repository, tag) = match name[name_start..].rfind(':') { + Some(offset) => { + let index = name_start + offset; + (&name[..index], Some(name[index + 1..].to_string())) + } + None => (name, None), + }; + + if repository.is_empty() { + bail!("Docker image reference '{reference}' has an empty repository"); + } + if tag.as_deref().is_some_and(str::is_empty) { + bail!("Docker image reference '{reference}' has an empty tag"); + } + + Ok(Self { + repository: repository.to_string(), + tag, + digest, + }) + } + + /// Reference used to fetch the image. Prefers the digest when the reference is pinned. + fn pull_reference(&self) -> String { + match (&self.digest, &self.tag) { + (Some(digest), _) => format!("{}@{}", self.repository, digest), + (None, Some(tag)) => format!("{}:{}", self.repository, tag), + (None, None) => format!("{}:latest", self.repository), + } + } +} + +/// A Docker image whose provenance was checked before it is allowed to execute. +/// +/// The only way to obtain one is [`ensure_rust_image`], and every command that runs the +/// image takes this type, so no build path can execute a bare mutable tag. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedImage { + /// Immutable reference handed to `docker run` (`repository@sha256:...`, or the local + /// image ID when verification was explicitly relaxed). + reference: String, + /// Repository the image is required to come from. + repository: String, + /// Registry digest. `None` only when the caller opted out of verification. + digest: Option, + /// Local content-addressed image ID, re-checked right before execution. + image_id: String, + /// Reference originally requested, kept for diagnostics and metadata. + requested: String, +} + +impl VerifiedImage { + /// Immutable reference to pass to Docker. + pub fn reference(&self) -> &str { + &self.reference + } + + /// Verified registry digest, when the image carries one. + pub fn digest(&self) -> Option<&str> { + self.digest.as_deref() + } + + /// Local image ID recorded at verification time. + pub fn image_id(&self) -> &str { + &self.image_id + } + + /// Reference the build asked for (tag form for unpinned builds). + pub fn requested(&self) -> &str { + &self.requested + } +} + +impl fmt::Display for VerifiedImage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.reference) + } +} /// Run command in the Docker container pub fn run_in_docker( - image: &str, + image: &VerifiedImage, args: &[String], mount_dir: &Path, work_dir: &Path, env_vars: &[(String, String)], rust_toolchain: &Option, ) -> Result<()> { + // Re-check the image immediately before execution: verification and `docker run` + // are separate daemon calls, and nothing else guarantees the image did not change + // in between. + verify_before_run(image)?; + let mount_dir = mount_dir .canonicalize() .with_context(|| format!("Failed to canonicalize mount dir: {}", mount_dir.display()))?; @@ -52,7 +166,7 @@ pub fn run_in_docker( cmd.args(["-e", &format!("RUSTUP_TOOLCHAIN={toolchain}")]); } - cmd.arg(image); + cmd.arg(image.reference()); cmd.args(args); eprintln!("Docker command: {:?}", cmd); @@ -66,48 +180,152 @@ pub fn run_in_docker( Ok(()) } -/// Get Docker image for builds -pub fn ensure_rust_image(image: &str) -> Result { +/// Resolve the build image to an immutable digest and verify it before any container runs. +/// +/// * `image` is `repository[:tag][@sha256:...]`. +/// * `expected_digest` pins the image: the resolved digest must match it or the build fails. +/// * `allow_unverified` permits an image with no registry digest for `repository` (a locally +/// built image). It never relaxes an explicit `expected_digest`. +pub fn ensure_rust_image( + image: &str, + expected_digest: Option<&str>, + allow_unverified: bool, +) -> Result { check_docker()?; verify_host_platform()?; - // Ensure image exists (pull if needed) - if !image_exists(image)? { - println!("Pulling base image: {image} ..."); - let status = Command::new("docker") - .args(["pull", "--platform", DOCKER_PLATFORM, image]) - .status()?; - if !status.success() { - bail!("Failed to get image: {}", image); + let image_ref = ImageRef::parse(image)?; + let expected = match (image_ref.digest.as_deref(), expected_digest) { + (Some(from_reference), Some(explicit)) => { + let explicit = parse_expected_digest(&image_ref.repository, explicit)?; + if from_reference != explicit { + bail!( + "Conflicting Docker image digests\n \ + from image reference: {from_reference}\n \ + from --docker-digest: {explicit}" + ); + } + Some(explicit) } + (Some(from_reference), None) => Some(from_reference.to_string()), + (None, Some(explicit)) => Some(parse_expected_digest(&image_ref.repository, explicit)?), + (None, None) => None, + }; + + let pull_reference = match &expected { + Some(digest) => format!("{}@{}", image_ref.repository, digest), + None => image_ref.pull_reference(), + }; + + let inspected = match inspect_image(&pull_reference)? { + Some(inspected) => inspected, + None => { + println!("Pulling base image: {pull_reference} ..."); + match pull_image(&pull_reference) { + Ok(()) => inspect_image(&pull_reference)?.ok_or_else(|| { + anyhow::anyhow!( + "Docker image {pull_reference} is missing after a successful pull" + ) + })?, + Err(pull_error) => { + // A pinned digest that cannot be fetched usually means the local image + // under the same reference is a different one. Report that mismatch + // instead of the pull failure, which hides the actual problem. + if let (Some(expected), Some(local)) = ( + expected.as_deref(), + inspect_image(&image_ref.pull_reference())?, + ) { + select_verified_digest( + &image_ref.repository, + &local.repo_digests, + Some(expected), + )?; + } + return Err(pull_error); + } + } + } + }; + + let digest = match select_verified_digest( + &image_ref.repository, + &inspected.repo_digests, + expected.as_deref(), + ) { + Ok(digest) => Some(digest), + // Only an unpinned build may fall back to the local image: a digest the caller + // asked for is a hard requirement. + Err(err) if allow_unverified && expected.is_none() => { + eprintln!("WARN: {err:#}"); + eprintln!( + "WARN: running unverified image {pull_reference} because image verification \ + was explicitly relaxed (--allow-unverified-docker-image)" + ); + None + } + Err(err) => return Err(err), + }; + + // Run by digest (or by image ID) so the mutable tag cannot be repointed between now + // and execution. + let reference = match &digest { + Some(digest) => format!("{}@{}", image_ref.repository, digest), + None => inspected.id.clone(), + }; + + match (&digest, &image_ref.digest, &expected) { + (Some(digest), None, None) => { + println!("Using image: {reference} (resolved from {image})"); + eprintln!( + "WARN: Docker tag '{image}' is mutable. Pin release and system builds with \ + --docker-digest {digest}" + ); + } + _ => println!("Using image: {reference}"), } - println!("Using image: {image}"); - Ok(image.to_string()) + Ok(VerifiedImage { + reference, + repository: image_ref.repository, + digest, + image_id: inspected.id, + requested: image.to_string(), + }) } /// PUBLIC UTILS /// Get Rust toolchain version from Docker image -pub fn get_image_rustc_version(image: &str) -> Result { +pub fn get_image_rustc_version(image: &VerifiedImage) -> Result { + verify_before_run(image)?; + let output = Command::new("docker") - .args(["run", "--rm", image, "rustc", "--version", "--verbose"]) + .args([ + "run", + "--rm", + image.reference(), + "rustc", + "--version", + "--verbose", + ]) .output() .context("Failed to get Rust version from Docker image")?; if !output.status.success() { - bail!("Failed to get Rust version from image: {}", image); + bail!("Failed to get Rust version from image: {image}"); } Ok(parse_rustc_version(String::from_utf8_lossy(&output.stdout))) } /// Get platform information from Docker image -pub fn get_image_platform(image: &str) -> Result { +pub fn get_image_platform(image: &VerifiedImage) -> Result { + verify_before_run(image)?; + let output = Command::new("docker") .args([ "run", "--rm", - image, + image.reference(), "sh", "-c", "echo $(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)", @@ -116,27 +334,199 @@ pub fn get_image_platform(image: &str) -> Result { .context("Failed to get platform info from Docker image")?; if !output.status.success() { - bail!("Failed to get platform info from image: {}", image); + bail!("Failed to get platform info from image: {image}"); } Ok(String::from_utf8(output.stdout)?.trim().to_string()) } -/// Get Docker image ID for exact reproduction -pub fn get_image_id(image: &str) -> Result { +// Helper functions + +/// Result of `docker image inspect` for a single image. +#[derive(Debug, Clone, PartialEq, Eq)] +struct InspectedImage { + /// Content-addressed local image ID. + id: String, + /// `repository@sha256:...` entries recorded when the image was pulled. + repo_digests: Vec, +} + +/// Pick the registry digest the build is allowed to run. +/// +/// Rejects images that carry no digest for `repository`, which is what a locally poisoned +/// or retagged image looks like: it never came from the expected repository. +fn select_verified_digest( + repository: &str, + repo_digests: &[String], + expected: Option<&str>, +) -> Result { + let mut candidates: Vec<&str> = repo_digests + .iter() + .filter_map(|entry| { + let (entry_repository, digest) = entry.split_once('@')?; + (entry_repository == repository).then_some(digest) + }) + .collect(); + candidates.sort_unstable(); + candidates.dedup(); + + let found = || { + if candidates.is_empty() { + let others = repo_digests.join(", "); + if others.is_empty() { + "none (the image was never pulled from a registry)".to_string() + } else { + format!("none for this repository (image carries: {others})") + } + } else { + candidates.join(", ") + } + }; + + match expected { + Some(expected) => { + if candidates.contains(&expected) { + Ok(expected.to_string()) + } else { + bail!( + "Refusing to run Docker image: digest mismatch for {repository}\n \ + expected: {expected}\n \ + resolved: {}\n\ + The local image does not match the pinned digest. Remove it and let the \ + build pull {repository}@{expected}.", + found() + ) + } + } + None => match candidates.as_slice() { + [] => bail!( + "Refusing to run Docker image {repository}: no registry digest for this \ + repository\n resolved: {}\n\ + The local image was built or retagged locally, so its provenance cannot be \ + verified. Pull it from the registry, pin it with --docker-digest , \ + or pass --allow-unverified-docker-image to accept it.", + found() + ), + [only] => Ok(only.to_string()), + many => bail!( + "Refusing to run Docker image {repository}: it resolves to several registry \ + digests ({})\nPin the one you trust with --docker-digest .", + many.join(", ") + ), + }, + } +} + +/// Re-inspect an already verified image and fail unless it is still the same content. +fn verify_before_run(image: &VerifiedImage) -> Result<()> { + let inspected = inspect_image(image.reference())?.ok_or_else(|| { + anyhow::anyhow!( + "Docker image {} disappeared after verification", + image.reference() + ) + })?; + + if inspected.id != image.image_id { + bail!( + "Refusing to run Docker image {}: it changed after verification\n \ + verified image ID: {}\n current image ID: {}", + image.reference(), + image.image_id, + inspected.id + ); + } + + if let Some(digest) = image.digest() { + select_verified_digest(&image.repository, &inspected.repo_digests, Some(digest))?; + } + + Ok(()) +} + +/// Accept a pinned digest either bare (`sha256:...`) or as a full repository reference +/// (`repository@sha256:...`), which is the form `docker image inspect` reports. +fn parse_expected_digest(repository: &str, value: &str) -> Result { + let value = value.trim(); + + match value.rsplit_once('@') { + Some((prefix, digest)) => { + if !prefix.is_empty() && prefix != repository { + bail!( + "Pinned digest '{value}' belongs to repository '{prefix}', \ + but the build image is '{repository}'" + ); + } + validate_digest(digest) + } + None => validate_digest(value), + } +} + +fn validate_digest(digest: &str) -> Result { + let digest = digest.trim(); + let hex = digest.strip_prefix(DIGEST_PREFIX).ok_or_else(|| { + anyhow::anyhow!("Invalid image digest '{digest}': expected '{DIGEST_PREFIX}<64 hex chars>'") + })?; + + if hex.len() != DIGEST_HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + bail!("Invalid image digest '{digest}': expected '{DIGEST_PREFIX}<64 hex chars>'"); + } + + Ok(format!("{DIGEST_PREFIX}{}", hex.to_ascii_lowercase())) +} + +/// Parse the `{{.Id}}` + `{{.RepoDigests}}` inspect template output. +fn parse_inspect_output(stdout: &str) -> Result { + let mut lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()); + + let id = lines + .next() + .ok_or_else(|| anyhow::anyhow!("docker image inspect returned no image ID"))? + .to_string(); + + Ok(InspectedImage { + id, + repo_digests: lines.map(str::to_string).collect(), + }) +} + +/// Inspect an image, returning `None` when it is not present locally. +fn inspect_image(reference: &str) -> Result> { let output = Command::new("docker") - .args(["inspect", "--format", "{{.Id}}", image]) + .args([ + "image", + "inspect", + "--format", + "{{.Id}}\n{{range .RepoDigests}}{{.}}\n{{end}}", + reference, + ]) .output() .context("Failed to inspect Docker image")?; if !output.status.success() { - bail!("Failed to get image ID for: {}", image); + return Ok(None); } - Ok(String::from_utf8(output.stdout)?.trim().to_string()) + parse_inspect_output(&String::from_utf8_lossy(&output.stdout)) + .with_context(|| format!("Failed to inspect Docker image: {reference}")) + .map(Some) } -// Helper functions +fn pull_image(reference: &str) -> Result<()> { + let status = Command::new("docker") + .args(["pull", "--platform", DOCKER_PLATFORM, reference]) + .status() + .context("Failed to run docker pull")?; + + if !status.success() { + bail!("Failed to get image: {reference}"); + } + + Ok(()) +} fn check_docker() -> Result<()> { let output = Command::new("docker").args(["version"]).output(); @@ -177,11 +567,178 @@ fn verify_host_platform() -> Result<()> { Ok(()) } -fn image_exists(image: &str) -> Result { - let output = Command::new("docker") - .args(["images", "-q", image]) - .output() - .context("Failed to check Docker images")?; +#[cfg(test)] +mod tests { + use super::*; + + const REPOSITORY: &str = "ghcr.io/fluentlabs-xyz/fluentbase-build"; + const TRUSTED: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + const OTHER: &str = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; - Ok(!output.stdout.is_empty()) + fn repo_digest(repository: &str, digest: &str) -> String { + format!("{repository}@{digest}") + } + + #[test] + fn parses_tagged_reference() { + let parsed = ImageRef::parse(&format!("{REPOSITORY}:v0.1.0")).unwrap(); + assert_eq!(parsed.repository, REPOSITORY); + assert_eq!(parsed.tag.as_deref(), Some("v0.1.0")); + assert_eq!(parsed.digest, None); + assert_eq!(parsed.pull_reference(), format!("{REPOSITORY}:v0.1.0")); + } + + #[test] + fn parses_pinned_reference() { + let parsed = ImageRef::parse(&format!("{REPOSITORY}:v0.1.0@{TRUSTED}")).unwrap(); + assert_eq!(parsed.repository, REPOSITORY); + assert_eq!(parsed.tag.as_deref(), Some("v0.1.0")); + assert_eq!(parsed.digest.as_deref(), Some(TRUSTED)); + assert_eq!( + parsed.pull_reference(), + format!("{REPOSITORY}@{TRUSTED}"), + "a pinned reference must be fetched by digest" + ); + } + + #[test] + fn registry_port_is_not_a_tag() { + let parsed = ImageRef::parse("localhost:5000/fluentbase-build").unwrap(); + assert_eq!(parsed.repository, "localhost:5000/fluentbase-build"); + assert_eq!(parsed.tag, None); + + let parsed = ImageRef::parse("localhost:5000/fluentbase-build:v1").unwrap(); + assert_eq!(parsed.repository, "localhost:5000/fluentbase-build"); + assert_eq!(parsed.tag.as_deref(), Some("v1")); + } + + #[test] + fn rejects_malformed_references_and_digests() { + assert!(ImageRef::parse(" ").is_err()); + assert!(ImageRef::parse(&format!("{REPOSITORY}:")).is_err()); + assert!(ImageRef::parse(&format!("{REPOSITORY}@sha256:beef")).is_err()); + assert!(ImageRef::parse(&format!("{REPOSITORY}@md5:{}", "0".repeat(64))).is_err()); + assert!(validate_digest(&format!("sha256:{}", "z".repeat(64))).is_err()); + assert_eq!( + validate_digest(&format!("sha256:{}", "A".repeat(64))).unwrap(), + format!("sha256:{}", "a".repeat(64)), + "digests compare case-insensitively" + ); + } + + #[test] + fn accepts_pinned_digest_in_bare_and_repository_form() { + assert_eq!(parse_expected_digest(REPOSITORY, TRUSTED).unwrap(), TRUSTED); + assert_eq!( + parse_expected_digest(REPOSITORY, &repo_digest(REPOSITORY, TRUSTED)).unwrap(), + TRUSTED + ); + + // A digest that belongs to another repository is a configuration mistake. + let err = + parse_expected_digest(REPOSITORY, &repo_digest("other.example.com/image", TRUSTED)) + .unwrap_err(); + assert!( + err.to_string().contains("belongs to repository"), + "unexpected error: {err}" + ); + + // A local image ID is not a registry digest, but it is shaped like one; it is + // rejected later, when it fails to match any repository digest. + assert!(parse_expected_digest(REPOSITORY, "not-a-digest").is_err()); + } + + #[test] + fn accepts_image_pulled_from_the_expected_repository() { + let digests = vec![repo_digest(REPOSITORY, TRUSTED)]; + assert_eq!( + select_verified_digest(REPOSITORY, &digests, None).unwrap(), + TRUSTED + ); + assert_eq!( + select_verified_digest(REPOSITORY, &digests, Some(TRUSTED)).unwrap(), + TRUSTED + ); + } + + #[test] + fn rejects_locally_built_image_wearing_the_expected_tag() { + // A locally built image has no registry digest at all. + let err = select_verified_digest(REPOSITORY, &[], None).unwrap_err(); + assert!( + err.to_string().contains("no registry digest"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_foreign_image_retagged_as_the_build_image() { + // `docker tag evil/image ghcr.io/fluentlabs-xyz/fluentbase-build:v0.1.0` keeps the + // digest of the repository the image really came from. + let digests = vec![repo_digest("evil.example.com/image", TRUSTED)]; + + let err = select_verified_digest(REPOSITORY, &digests, None).unwrap_err(); + assert!( + err.to_string().contains("no registry digest"), + "unexpected error: {err}" + ); + + let err = select_verified_digest(REPOSITORY, &digests, Some(TRUSTED)).unwrap_err(); + assert!( + err.to_string().contains("digest mismatch"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_pinned_digest_mismatch() { + let digests = vec![repo_digest(REPOSITORY, OTHER)]; + let err = select_verified_digest(REPOSITORY, &digests, Some(TRUSTED)).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("digest mismatch"), "{message}"); + assert!(message.contains(TRUSTED), "{message}"); + assert!(message.contains(OTHER), "{message}"); + } + + #[test] + fn rejects_ambiguous_digests_unless_pinned() { + let digests = vec![ + repo_digest(REPOSITORY, TRUSTED), + repo_digest(REPOSITORY, OTHER), + ]; + assert!(select_verified_digest(REPOSITORY, &digests, None).is_err()); + assert_eq!( + select_verified_digest(REPOSITORY, &digests, Some(OTHER)).unwrap(), + OTHER + ); + } + + #[test] + fn parses_inspect_output() { + let stdout = format!( + "sha256:{id}\n{}\n{}\n\n", + repo_digest(REPOSITORY, TRUSTED), + repo_digest("mirror.example.com/fluentbase-build", OTHER), + id = "a".repeat(64), + ); + + let inspected = parse_inspect_output(&stdout).unwrap(); + assert_eq!(inspected.id, format!("sha256:{}", "a".repeat(64))); + assert_eq!( + inspected.repo_digests, + vec![ + repo_digest(REPOSITORY, TRUSTED), + repo_digest("mirror.example.com/fluentbase-build", OTHER), + ] + ); + + assert!(parse_inspect_output(" \n").is_err()); + } + + #[test] + fn image_without_repo_digests_parses_but_is_rejected() { + let inspected = parse_inspect_output(&format!("sha256:{}\n", "b".repeat(64))).unwrap(); + assert!(inspected.repo_digests.is_empty()); + assert!(select_verified_digest(REPOSITORY, &inspected.repo_digests, None).is_err()); + } } diff --git a/crates/build/src/generators/metadata.rs b/crates/build/src/generators/metadata.rs index a46c071ec..b26ba09c2 100644 --- a/crates/build/src/generators/metadata.rs +++ b/crates/build/src/generators/metadata.rs @@ -105,10 +105,15 @@ pub struct BuildConfig { #[derive(Debug, Serialize, Deserialize)] pub struct DockerImageInfo { - /// Full image name used for build + /// Immutable reference the build actually ran pub image_used: String, + /// Reference requested before digest resolution + pub image_requested: String, /// Base tag requested pub base_tag: String, + /// Registry digest verified before the container ran + #[serde(skip_serializing_if = "Option::is_none")] + pub digest: Option, /// Docker image ID for exact reproduction #[serde(skip_serializing_if = "Option::is_none")] pub image_id: Option, @@ -155,7 +160,7 @@ pub fn generate( args: &crate::BuildArgs, wasm_data: &[u8], rwasm_data: Option<&[u8]>, - docker_image_used: Option<&str>, + docker_image_used: Option<&docker::VerifiedImage>, rust_toolchain: Option<&str>, ) -> Result { // Load package metadata @@ -199,11 +204,14 @@ pub fn generate( os_version: get_os_version(), }; - // Docker image info if applicable + // Docker image info if applicable. The digest and image ID come from the verification + // performed before the build ran, so the metadata records what was actually checked. let docker_image = docker_image_used.map(|image| DockerImageInfo { - image_used: image.to_string(), + image_used: image.reference().to_string(), + image_requested: image.requested().to_string(), base_tag: args.docker_tag.clone(), - image_id: docker::get_image_id(image).ok(), + digest: image.digest().map(str::to_string), + image_id: Some(image.image_id().to_string()), }); // Build config diff --git a/crates/build/src/generators/mod.rs b/crates/build/src/generators/mod.rs index ee7bae083..9d74a873c 100644 --- a/crates/build/src/generators/mod.rs +++ b/crates/build/src/generators/mod.rs @@ -1,4 +1,3 @@ pub mod foundry; pub mod metadata; pub mod solidity; -mod struct_parser; diff --git a/crates/build/src/generators/solidity.rs b/crates/build/src/generators/solidity.rs index f8a118ebc..b4c621a01 100644 --- a/crates/build/src/generators/solidity.rs +++ b/crates/build/src/generators/solidity.rs @@ -1,20 +1,21 @@ //! Solidity ABI and interface generation from Rust smart contracts -use crate::generators::struct_parser::{enrich_abi_entry, parse_structs_from_dir}; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use convert_case::{Case, Casing}; use fluentbase_sdk_derive_core::{ - constructor::{process_constructor, Constructor}, - router::{process_router, Router}, + abi::{ + function::FunctionABI, + structs::{StructRegistry, StructResolver}, + }, + constructor::{process_constructor_with_structs, Constructor}, + method::ParsedMethod, + router::{process_router_with_structs, Router}, }; use proc_macro2::TokenStream as TokenStream2; use quote::ToTokens; use serde_json::Value; -use std::{ - collections::{HashMap, HashSet}, - path::Path, -}; -use syn::{parse_file, visit::Visit, Attribute, DeriveInput, ItemImpl}; +use std::{collections::HashSet, path::Path}; +use syn::{parse_file, visit::Visit, Attribute, ImplItemFn, ItemImpl}; /// Solidity ABI represented as JSON values pub type Abi = Vec; @@ -40,14 +41,17 @@ pub fn generate_abi(contract_dir: &Path) -> Result { )); }; - // Parse all structs from the src directory - let structs = parse_structs_from_dir(&src_dir)?; + // Parse all Codec structs reachable from the crate root, so that struct parameters are + // expanded into their components before any selector is calculated - exactly as the + // #[router] macro does when it compiles the dispatch table + let structs = StructRegistry::parse_crate(&main_file)?; + let resolver = StructResolver::registry(structs); // Parse contract methods (routers and constructors) from the main file - let methods = parse_contract_methods(&main_file)?; + let methods = parse_contract_methods(&main_file, &resolver)?; - // Generate ABI from contract methods with struct enrichment - generate_abi_from_methods(&methods, &structs) + // Generate ABI from contract methods + generate_abi_from_methods(&methods, &resolver) } /// Generate Solidity interface from ABI @@ -113,7 +117,7 @@ struct ContractMethods { } /// Parses a Rust file and extracts all contract elements (routers and constructors) -fn parse_contract_methods(path: &Path) -> Result { +fn parse_contract_methods(path: &Path, resolver: &StructResolver) -> Result { // Read file content let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read file: {}", path.display()))?; @@ -123,7 +127,7 @@ fn parse_contract_methods(path: &Path) -> Result { parse_file(&content).map_err(|e| anyhow::anyhow!("Failed to parse Rust file: {}", e))?; // Find contract methods - let mut finder = ContractMethodFinder::new(); + let mut finder = ContractMethodFinder::new(resolver); finder.visit_file(&ast); // Return first error if any occurred during processing @@ -137,23 +141,19 @@ fn parse_contract_methods(path: &Path) -> Result { }) } -/// Generates ABI from parsed contract methods with struct enrichment -fn generate_abi_from_methods( - methods: &ContractMethods, - structs: &HashMap, -) -> Result { +/// Generates ABI from parsed contract methods +/// +/// Every entry comes from the same resolved representation the router selector was calculated +/// from, and each function entry is checked against that selector before it is published. +fn generate_abi_from_methods(methods: &ContractMethods, resolver: &StructResolver) -> Result { let mut entries = Vec::new(); // Process constructor first (they appear first in standard ABIs) if let Some(constructor) = &methods.constructor { - let constructor_method = constructor.constructor_method(); - if let Ok(constructor_abi) = constructor_method.parsed_signature().constructor_abi() { - if let Ok(mut json) = constructor_abi.to_json_value() { - // Enrich the ABI entry with struct components - enrich_abi_entry(&mut json, structs)?; - entries.push(json); - } - } + entries.push(constructor_entry( + constructor.constructor_method(), + resolver, + )?); } // Process routers - take first router if multiple exist @@ -162,48 +162,111 @@ fn generate_abi_from_methods( // Skip it if we already processed standalone constructors if methods.constructor.is_none() { if let Some(constructor) = router.constructor() { - if let Ok(constructor_abi) = constructor.parsed_signature().constructor_abi() { - if let Ok(mut json) = constructor_abi.to_json_value() { - enrich_abi_entry(&mut json, structs)?; - entries.push(json); - } - } + entries.push(constructor_entry(constructor, resolver)?); } } // Add all functions from the router for method in router.available_methods() { - if let Ok(func_abi) = method.parsed_signature().function_abi() { - if let Ok(mut json) = func_abi.to_json_value() { - enrich_abi_entry(&mut json, structs)?; - entries.push(json); - } - } + let name = method.parsed_signature().rust_name(); + let abi = method.function_abi().ok_or_else(|| { + anyhow!( + "method `{name}` pins a custom selector for a signature that cannot be \ + derived from its Rust types, so no ABI entry can be published for it" + ) + })?; + + let entry = abi + .to_json_value() + .with_context(|| format!("Failed to serialize the ABI entry of `{name}`"))?; + + verify_selector(&entry, method.function_id(), method.signature()) + .with_context(|| format!("ABI entry of `{name}` does not match its router"))?; + + entries.push(entry); } } Ok(entries) } +/// Serializes a constructor into its ABI entry +/// +/// Constructors are called by the deployer without a selector, so there is nothing to cross-check +/// here - only the parameter components matter. +fn constructor_entry( + constructor: &ParsedMethod, + resolver: &StructResolver, +) -> Result { + constructor + .parsed_signature() + .constructor_abi_with(resolver) + .map_err(|error| anyhow!("Failed to build the constructor ABI: {error}"))? + .to_json_value() + .context("Failed to serialize the constructor ABI") +} + +/// Rejects an ABI entry whose selector differs from the one the compiled router dispatches on +/// +/// The published entry is the only thing callers see, so a selector recomputed from it has to +/// reproduce the router's. When it does not, tooling would encode calls the deployed contract +/// rejects - or, on a collision, calls it routes somewhere else entirely. +fn verify_selector(entry: &Value, router_selector: [u8; 4], router_signature: &str) -> Result<()> { + let published = FunctionABI::from_json_value(entry.clone()) + .context("Failed to read back the generated ABI entry")?; + + let published_signature = published + .signature() + .map_err(|error| anyhow!("Failed to derive the signature of the ABI entry: {error}"))?; + let published_selector = published + .function_id() + .map_err(|error| anyhow!("Failed to derive the selector of the ABI entry: {error}"))?; + + if published_selector == router_selector { + return Ok(()); + } + + Err(anyhow!( + "published signature `{published_signature}` hashes to 0x{}, but the router dispatches \ + `{router_signature}` on 0x{}. Callers using the published ABI would not reach this \ + method; changing the router selector is an ABI migration.", + hex_selector(published_selector), + hex_selector(router_selector), + )) +} + +fn hex_selector(selector: [u8; 4]) -> String { + selector + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() +} + /// Internal visitor for finding contract elements (routers and constructors) -struct ContractMethodFinder { +struct ContractMethodFinder<'a> { routers: Vec, constructor: Option, errors: Vec, + resolver: &'a StructResolver, } -impl ContractMethodFinder { - fn new() -> Self { +impl<'a> ContractMethodFinder<'a> { + fn new(resolver: &'a StructResolver) -> Self { Self { routers: Vec::new(), constructor: None, errors: Vec::new(), + resolver, } } fn process_router_impl(&mut self, attr: &Attribute, impl_block: &ItemImpl) { match extract_attribute_tokens(attr) { - Ok(attr_tokens) => match process_router(attr_tokens, impl_block.to_token_stream()) { + Ok(attr_tokens) => match process_router_with_structs( + attr_tokens, + impl_block.to_token_stream(), + self.resolver, + ) { Ok(router) => self.routers.push(router), Err(error) => self.errors.push(error), }, @@ -213,18 +276,20 @@ impl ContractMethodFinder { fn process_constructor_impl(&mut self, attr: &Attribute, impl_block: &ItemImpl) { match extract_attribute_tokens(attr) { - Ok(attr_tokens) => { - match process_constructor(attr_tokens, impl_block.to_token_stream()) { - Ok(constructor) => self.constructor = Some(constructor), - Err(error) => self.errors.push(error), - } - } + Ok(attr_tokens) => match process_constructor_with_structs( + attr_tokens, + impl_block.to_token_stream(), + self.resolver, + ) { + Ok(constructor) => self.constructor = Some(constructor), + Err(error) => self.errors.push(error), + }, Err(error) => self.errors.push(error), } } } -impl<'ast> Visit<'ast> for ContractMethodFinder { +impl<'ast> Visit<'ast> for ContractMethodFinder<'_> { fn visit_item_impl(&mut self, node: &'ast ItemImpl) { // Look for router or constructor attributes for attr in &node.attrs { diff --git a/crates/build/src/generators/struct_parser.rs b/crates/build/src/generators/struct_parser.rs deleted file mode 100644 index f2fa7fd3e..000000000 --- a/crates/build/src/generators/struct_parser.rs +++ /dev/null @@ -1,483 +0,0 @@ -//! Parser for extracting structs with Codec derive from Rust source files - -use anyhow::{Context, Result}; -use fluentbase_sdk_derive_core::abi::parameter::Parameter; -use serde_json::Value; -use std::{collections::HashMap, path::Path}; -use syn::{ - parse::Parser, parse_file, punctuated::Punctuated, visit::Visit, Attribute, DeriveInput, - ItemStruct, Meta, Path as SynPath, Token, -}; - -/// Parse structs from all .rs files in a directory -/// -/// # Arguments -/// * `dir` - Path to the directory containing Rust source files -/// -/// # Returns -/// * `HashMap` - Map of struct names to their parsed representations -pub fn parse_structs_from_dir(dir: &Path) -> Result> { - let mut all_structs = HashMap::new(); - - // Walk through all .rs files in the directory - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("rs") { - // Parse structs from this file - match parse_structs(&path) { - Ok(structs) => { - all_structs.extend(structs); - } - Err(e) => { - // Log warning but continue processing other files - eprintln!("Warning: Failed to parse structs from {path:?}: {e}"); - } - } - } - } - - Ok(all_structs) -} - -/// Parse structs with #[derive(Codec)] from a Rust source file -/// -/// # Arguments -/// * `path` - Path to the Rust source file -/// -/// # Returns -/// * `HashMap` - Map of struct names to their parsed representations -pub fn parse_structs(path: &Path) -> Result> { - // Read file content - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read file: {}", path.display()))?; - - // Parse Rust syntax - let ast = - parse_file(&content).map_err(|e| anyhow::anyhow!("Failed to parse Rust file: {}", e))?; - - // Find structs with Codec derive - let mut collector = StructCollector::new(); - collector.visit_file(&ast); - - Ok(collector.structs) -} - -/// Enrich a complete ABI entry (function) with struct components -/// -/// # Arguments -/// * `entry` - Mutable reference to the ABI entry (JSON value) -/// * `structs` - Registry of parsed struct definitions -/// -/// # Returns -/// * `Result<()>` - Ok if enrichment succeeded -pub fn enrich_abi_entry(entry: &mut Value, structs: &HashMap) -> Result<()> { - // Enrich inputs - if let Some(inputs) = entry.get_mut("inputs") { - enrich_parameters(inputs, structs)?; - } - - // Enrich outputs - if let Some(outputs) = entry.get_mut("outputs") { - enrich_parameters(outputs, structs)?; - } - - Ok(()) -} - -/// Enrich parameters in ABI with struct component information -/// -/// # Arguments -/// * `params` - Mutable reference to parameters array (JSON value) -/// * `structs` - Registry of parsed struct definitions -/// -/// # Returns -/// * `Result<()>` - Ok if enrichment succeeded -pub fn enrich_parameters(params: &mut Value, structs: &HashMap) -> Result<()> { - // params should be an array of parameters - if let Some(params_array) = params.as_array_mut() { - for param in params_array.iter_mut() { - enrich_single_parameter(param, structs)?; - } - } - Ok(()) -} - -/// Visitor for collecting structs with #[derive(Codec)] -struct StructCollector { - structs: HashMap, -} - -impl StructCollector { - fn new() -> Self { - Self { - structs: HashMap::new(), - } - } - - /// Check if attributes contain #[derive(...)] with Codec - /// Returns true if attributes contain `#[derive(Codec)]` - fn has_codec_derive(attrs: &[Attribute]) -> bool { - attrs.iter().any(|attr| match &attr.meta { - Meta::List(list) if list.path.is_ident("derive") => { - let derives = Punctuated::::parse_terminated - .parse2(list.tokens.clone()) - .ok(); - - derives - .map(|d| d.iter().any(|p| p.is_ident("Codec"))) - .unwrap_or(false) - } - _ => false, - }) - } - - /// Convert ItemStruct to DeriveInput - fn item_struct_to_derive_input(item: &ItemStruct) -> DeriveInput { - DeriveInput { - attrs: item.attrs.clone(), - vis: item.vis.clone(), - ident: item.ident.clone(), - generics: item.generics.clone(), - data: syn::Data::Struct(syn::DataStruct { - struct_token: item.struct_token, - fields: item.fields.clone(), - semi_token: item.semi_token, - }), - } - } -} - -impl<'ast> Visit<'ast> for StructCollector { - fn visit_item_struct(&mut self, node: &'ast ItemStruct) { - // Check if this struct has #[derive(Codec)] - if Self::has_codec_derive(&node.attrs) { - let struct_name = node.ident.to_string(); - let derive_input = Self::item_struct_to_derive_input(node); - - // Store the struct for later use - self.structs.insert(struct_name, derive_input); - } - - // Continue visiting nested items - syn::visit::visit_item_struct(self, node); - } -} - -/// Enrich a single parameter with struct components if applicable -fn enrich_single_parameter( - param: &mut Value, - structs: &HashMap, -) -> Result<()> { - // Check if this parameter is a struct (tuple with struct internal type) - if param["type"] == "tuple" { - if let Some(internal_type) = param.get("internalType").and_then(Value::as_str) { - if let Some(struct_name) = internal_type.strip_prefix("struct ") { - // Found a struct parameter, look it up in our registry - if let Some(derive_input) = structs.get(struct_name) { - // Use Parameter::from_derive_input to get proper components - match Parameter::from_derive_input(derive_input) { - Ok(param_with_components) => { - // Serialize the Parameter to JSON to extract components - if let Ok(param_json) = serde_json::to_value(¶m_with_components) { - // Replace empty components with the correct ones - if let Some(components) = param_json.get("components") { - param["components"] = components.clone(); - } - } - } - Err(e) => { - // Log warning but continue processing - eprintln!("Warning: Failed to enrich struct {struct_name}: {e:?}"); - } - } - } - } - } - - // Recursively process nested components (for nested structs) - if let Some(components) = param.get_mut("components").and_then(Value::as_array_mut) { - for component in components.iter_mut() { - enrich_single_parameter(component, structs)?; - } - } - } - // FIX: Handle tuple[] (arrays of structs) - else if param["type"] == "tuple[]" { - // For tuple arrays, check if it's an array of structs - if let Some(internal_type) = param.get("internalType").and_then(Value::as_str) { - if let Some(struct_name) = internal_type - .strip_prefix("struct ") - .and_then(|s| s.strip_suffix("[]")) - { - // This is an array of structs - if let Some(derive_input) = structs.get(struct_name) { - match Parameter::from_derive_input(derive_input) { - Ok(param_with_components) => { - if let Ok(param_json) = serde_json::to_value(¶m_with_components) { - if let Some(components) = param_json.get("components") { - param["components"] = components.clone(); - } - } - } - Err(e) => { - eprintln!( - "Warning: Failed to enrich struct array {struct_name}: {e:?}" - ); - } - } - } - } - } - - // Recursively process components if they exist - if let Some(components) = param.get_mut("components").and_then(Value::as_array_mut) { - for component in components.iter_mut() { - enrich_single_parameter(component, structs)?; - } - } - } - // Original handling for old-style arrays (kept for compatibility) - else if param["type"] - .as_str() - .map(|s| s.ends_with("[]")) - .unwrap_or(false) - { - // Handle arrays of structs (backward compatibility) - if let Some(internal_type) = param.get("internalType").and_then(Value::as_str) { - if let Some(struct_name) = internal_type - .strip_prefix("struct ") - .and_then(|s| s.strip_suffix("[]")) - { - // This is an array of structs - if let Some(derive_input) = structs.get(struct_name) { - match Parameter::from_derive_input(derive_input) { - Ok(param_with_components) => { - if let Ok(param_json) = serde_json::to_value(¶m_with_components) { - if let Some(components) = param_json.get("components") { - param["components"] = components.clone(); - } - } - } - Err(e) => { - eprintln!( - "Warning: Failed to enrich struct array {struct_name}: {e:?}" - ); - } - } - } - } - } - } - - Ok(()) -} -#[cfg(test)] -mod tests { - mod parse { - use crate::generators::struct_parser::parse_structs; - use std::fs; - use tempfile::TempDir; - - /// Helper to create a temporary Rust file - fn create_temp_rust_file(content: &str) -> (TempDir, std::path::PathBuf) { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.rs"); - fs::write(&file_path, content).unwrap(); - (temp_dir, file_path) - } - - #[test] - fn test_parse_simple_struct_with_codec() { - let content = r#" -use fluentbase_sdk::codec::Codec; -use fluentbase_sdk::U256; - -#[derive(Codec, Debug, Clone)] -pub struct TestStruct { - pub field1: U256, - pub field2: bool, - pub field3: Address, -} - -#[derive(Debug, Clone)] -pub struct StructWithoutCodec { - pub field1: u32, -} - -#[derive(Codec)] -pub struct AnotherCodecStruct { - pub value: U256, -} -"#; - - let (_temp_dir, file_path) = create_temp_rust_file(content); - let structs = parse_structs(&file_path).unwrap(); - - // Should find exactly 2 structs with Codec - assert_eq!(structs.len(), 2); - - // Check that we found the right structs - assert!(structs.contains_key("TestStruct")); - assert!(structs.contains_key("AnotherCodecStruct")); - assert!(!structs.contains_key("StructWithoutCodec")); - - // Verify the TestStruct has correct fields - let test_struct = &structs["TestStruct"]; - if let syn::Data::Struct(data) = &test_struct.data { - let field_names: Vec = data - .fields - .iter() - .filter_map(|f| f.ident.as_ref().map(|i| i.to_string())) - .collect(); - - assert_eq!(field_names, vec!["field1", "field2", "field3"]); - } else { - panic!("Expected struct data"); - } - } - - #[test] - fn test_parse_nested_structs() { - let content = r#" -use fluentbase_sdk::codec::Codec; -use fluentbase_sdk::U256; - -mod inner { - use super::*; - - #[derive(Codec)] - pub struct InnerStruct { - pub value: U256, - } -} - -#[derive(Codec, Debug)] -pub struct OuterStruct { - pub inner: inner::InnerStruct, - pub data: U256, -} -"#; - - let (_temp_dir, file_path) = create_temp_rust_file(content); - let structs = parse_structs(&file_path).unwrap(); - - // Should find both inner and outer structs - assert_eq!(structs.len(), 2); - assert!(structs.contains_key("InnerStruct")); - assert!(structs.contains_key("OuterStruct")); - } - - #[test] - fn test_empty_file() { - let content = r#" -// Empty file with no structs -use fluentbase_sdk::codec::Codec; -"#; - - let (_temp_dir, file_path) = create_temp_rust_file(content); - let structs = parse_structs(&file_path).unwrap(); - - assert_eq!(structs.len(), 0); - } - - #[test] - fn test_struct_with_unnamed_fields() { - let content = r#" -use fluentbase_sdk::codec::Codec; -use fluentbase_sdk::U256; - -#[derive(Codec)] -pub struct TupleStruct(pub U256, pub bool); - -#[derive(Codec)] -pub struct UnitStruct; -"#; - - let (_temp_dir, file_path) = create_temp_rust_file(content); - let structs = parse_structs(&file_path).unwrap(); - - assert_eq!(structs.len(), 2); - assert!(structs.contains_key("TupleStruct")); - assert!(structs.contains_key("UnitStruct")); - } - } - mod enrich { - use crate::generators::struct_parser::enrich_parameters; - use serde_json::json; - use std::collections::HashMap; - use syn::{parse_quote, DeriveInput}; - - /// Helper to create a test DeriveInput for a struct - fn create_test_struct(name: &str, fields: Vec<(&str, &str)>) -> DeriveInput { - use quote::format_ident; - - let ident = format_ident!("{}", name); - - // Build the field list directly in the parse_quote macro - let field_tokens = fields - .into_iter() - .map(|(field_name, field_type)| { - let field_ident = format_ident!("{}", field_name); - let type_ident = format_ident!("{}", field_type); - quote::quote! { - pub #field_ident: #type_ident - } - }) - .collect::>(); - - parse_quote! { - #[derive(Codec)] - pub struct #ident { - #(#field_tokens),* - } - } - } - - #[test] - fn test_enrich_simple_struct_parameter() { - // Create a struct registry with SlippageParams - let mut structs = HashMap::new(); - structs.insert( - "SlippageParams".to_string(), - create_test_struct( - "SlippageParams", - vec![ - ("amount_in", "U256"), - ("reserve_in", "U256"), - ("reserve_out", "U256"), - ("fee_rate", "U256"), - ], - ), - ); - - // Create a parameter with empty components - let mut params = json!([{ - "name": "params", - "type": "tuple", - "internalType": "struct SlippageParams", - "components": [] // Empty components to be filled - }]); - - // Enrich the parameters - enrich_parameters(&mut params, &structs).unwrap(); - - // Check that components were added - let components = params[0]["components"].as_array().unwrap(); - assert_eq!(components.len(), 4, "Should have 4 components"); - - // Verify field names - assert_eq!(components[0]["name"], "amount_in"); - assert_eq!(components[1]["name"], "reserve_in"); - assert_eq!(components[2]["name"], "reserve_out"); - assert_eq!(components[3]["name"], "fee_rate"); - - // Verify field types (U256 should map to uint256) - assert_eq!(components[0]["type"], "uint256"); - assert_eq!(components[1]["type"], "uint256"); - assert_eq!(components[2]["type"], "uint256"); - assert_eq!(components[3]["type"], "uint256"); - } - } -} diff --git a/crates/build/src/lib.rs b/crates/build/src/lib.rs index b84ce564a..242cf29e4 100644 --- a/crates/build/src/lib.rs +++ b/crates/build/src/lib.rs @@ -21,6 +21,11 @@ pub const DEFAULT_DOCKER_TAG: &str = concat!("v", env!("CARGO_PKG_VERSION")); pub const DOCKER_PLATFORM: &str = "linux/amd64"; pub const CARGO_CACHE_VOLUME: &str = "fluentbase-cargo-cache"; +/// Pins the build image to an immutable digest (`sha256:...`). +pub const ENV_DOCKER_DIGEST: &str = "FLUENTBASE_BUILD_DOCKER_DIGEST"; +/// Accepts a build image whose provenance cannot be verified. +pub const ENV_ALLOW_UNVERIFIED_IMAGE: &str = "FLUENTBASE_BUILD_ALLOW_UNVERIFIED_IMAGE"; + pub const DEFAULT_STACK_SIZE: u32 = 128 * 1024; // 128 KB pub const BUILD_TARGET: &str = "wasm32-unknown-unknown"; pub const HELPER_TARGET_SUBDIR: &str = "wasm-compilation"; @@ -75,6 +80,20 @@ pub struct BuildArgs { #[arg(long, default_value = DEFAULT_DOCKER_TAG)] pub docker_tag: String, + /// Pin the Docker image to an immutable digest (`sha256:<64 hex chars>`). + /// + /// The build fails before any container runs if the image resolves to a different + /// digest. Required for release and system builds, where a mutable tag is not enough. + #[arg(long, env = ENV_DOCKER_DIGEST)] + pub docker_digest: Option, + + /// Accept a Docker image that carries no registry digest for the expected repository. + /// + /// Needed when running a locally built image; provenance cannot be verified, so never + /// use it for release or system builds. Ignored when `--docker-digest` is set. + #[arg(long, env = ENV_ALLOW_UNVERIFIED_IMAGE)] + pub allow_unverified_docker_image: bool, + /// Root directory to mount in Docker (defaults to current directory) #[arg(long)] pub mount_dir: Option, @@ -144,6 +163,10 @@ impl Default for BuildArgs { docker: true, docker_image: DEFAULT_DOCKER_IMAGE.to_string(), docker_tag: DEFAULT_DOCKER_TAG.to_string(), + // Build scripts construct `BuildArgs` directly, so the environment overrides + // clap would apply are resolved here as well. + docker_digest: env_docker_digest(), + allow_unverified_docker_image: env_allow_unverified_image(), mount_dir: None, rust_version: None, use_toolchain_file: false, // by default use rust from the image @@ -161,7 +184,49 @@ impl Default for BuildArgs { } } +fn env_docker_digest() -> Option { + env::var(ENV_DOCKER_DIGEST) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn env_allow_unverified_image() -> bool { + env::var(ENV_ALLOW_UNVERIFIED_IMAGE) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on")) +} + impl BuildArgs { + /// Full reference of the build image. + /// + /// `docker_image` may already carry a tag or a digest, in which case it is used as is; + /// otherwise the configured tag is appended. + pub fn docker_image_reference(&self) -> String { + let image = self.docker_image.trim(); + let has_tag_or_digest = image.contains('@') + || image + .rsplit('/') + .next() + .is_some_and(|name| name.contains(':')); + + if has_tag_or_digest { + image.to_string() + } else { + format!("{image}:{}", self.docker_tag) + } + } + + /// Resolve and verify the build image before anything is allowed to run in it. + pub fn ensure_docker_image(&self) -> anyhow::Result { + docker::ensure_rust_image( + &self.docker_image_reference(), + self.docker_digest.as_deref(), + self.allow_unverified_docker_image, + ) + } + pub fn toolchain_version(&self, contract_dir: &Path) -> Option { if let Some(version) = &self.rust_version { return Some(version.clone()); @@ -337,3 +402,54 @@ impl BuildArgs { flags.join("\x1f") } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn cli_definition_is_valid() { + BuildArgs::command().debug_assert(); + } + + #[test] + fn appends_the_tag_only_when_the_image_has_none() { + let digest = format!("sha256:{}", "a".repeat(64)); + let args = BuildArgs { + docker_tag: "v1.2.3".to_string(), + ..Default::default() + }; + + let tagged = BuildArgs { + docker_image: DEFAULT_DOCKER_IMAGE.to_string(), + ..args.clone() + }; + assert_eq!( + tagged.docker_image_reference(), + format!("{DEFAULT_DOCKER_IMAGE}:v1.2.3") + ); + + // An image that already carries a tag or digest is used verbatim. + for image in [ + format!("{DEFAULT_DOCKER_IMAGE}:custom"), + format!("{DEFAULT_DOCKER_IMAGE}@{digest}"), + ] { + let pinned = BuildArgs { + docker_image: image.clone(), + ..args.clone() + }; + assert_eq!(pinned.docker_image_reference(), image); + } + + // A registry port is not a tag. + let with_port = BuildArgs { + docker_image: "localhost:5000/fluentbase-build".to_string(), + ..args + }; + assert_eq!( + with_port.docker_image_reference(), + "localhost:5000/fluentbase-build:v1.2.3" + ); + } +} diff --git a/crates/build/tests/abi_generation.rs b/crates/build/tests/abi_generation.rs index 3ff37824c..146211aa4 100644 --- a/crates/build/tests/abi_generation.rs +++ b/crates/build/tests/abi_generation.rs @@ -1,9 +1,16 @@ // tests/abi_generation.rs // ABI generation tests with struct support using insta snapshots -use fluentbase_build::solidity::generate_abi; +use fluentbase_build::solidity::{generate_abi, generate_interface, Abi}; +use fluentbase_sdk_derive_core::{ + abi::structs::{StructRegistry, StructResolver}, + router::process_router_with_structs, +}; use insta::{assert_json_snapshot, Settings}; -use std::fs; +use quote::ToTokens; +use serde_json::Value; +use std::{fs, path::Path}; +use syn::{visit::Visit, ItemImpl}; use tempfile::TempDir; /// Helper to create the project from fixture @@ -70,6 +77,392 @@ fn edge_cases_struct_abi() { }); } +/// Root file of a crate whose modules both declare a `Config` struct +const DUPLICATE_NAMES_ROOT: &str = r#" +#![cfg_attr(target_arch = "wasm32", no_std)] +extern crate fluentbase_sdk; + +use fluentbase_sdk::{basic_entrypoint, derive::router, Address, SharedAPI, U256}; + +mod a; +mod b; + +#[derive(Default)] +pub struct DuplicateNames { + sdk: SDK, +} + +#[router(mode = "solidity")] +impl DuplicateNames { + pub fn set_a(&mut self, config: a::Config) -> U256 { + config.value + } + + pub fn set_b(&mut self, config: b::Config) -> Address { + config.owner + } +} + +basic_entrypoint!(DuplicateNames); +"#; + +const MODULE_A: &str = r#" +use fluentbase_sdk::{derive::Codec, U256}; + +#[derive(Codec, Debug, Clone)] +pub struct Config { + pub value: U256, + pub enabled: bool, +} +"#; + +const MODULE_B: &str = r#" +use fluentbase_sdk::{derive::Codec, Address, U256}; + +#[derive(Codec, Debug, Clone)] +pub struct Config { + pub owner: Address, + pub limit: U256, + pub label: String, +} +"#; + +/// Write the duplicate-name crate, creating the module files in the given order +fn duplicate_names_project(module_order: &[&str]) -> (TempDir, std::path::PathBuf) { + let temp_dir = TempDir::new().expect("create temp dir"); + let project_path = temp_dir.path().to_path_buf(); + let src_dir = project_path.join("src"); + fs::create_dir_all(&src_dir).expect("create src dir"); + + for module in module_order { + let content = match *module { + "a" => MODULE_A, + "b" => MODULE_B, + other => panic!("unknown module {other}"), + }; + fs::write(src_dir.join(format!("{module}.rs")), content).expect("write module"); + } + fs::write(src_dir.join("lib.rs"), DUPLICATE_NAMES_ROOT).expect("write lib.rs"); + + (temp_dir, project_path) +} + +/// Same struct name in two modules must not collapse into one ABI definition +#[test] +fn duplicate_struct_names_resolve_per_module() { + let (_temp, project) = duplicate_names_project(&["a", "b"]); + let abi = generate_abi(&project).expect("generate ABI"); + + let set_a = abi + .iter() + .find(|entry| entry["name"] == "setA") + .expect("setA in ABI"); + let a_components = set_a["inputs"][0]["components"] + .as_array() + .expect("components for a::Config"); + assert_eq!( + a_components + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect::>(), + vec!["value", "enabled"] + ); + + let set_b = abi + .iter() + .find(|entry| entry["name"] == "setB") + .expect("setB in ABI"); + let b_components = set_b["inputs"][0]["components"] + .as_array() + .expect("components for b::Config"); + assert_eq!( + b_components + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect::>(), + vec!["owner", "limit", "label"] + ); +} + +/// The ABI must not depend on the order the source files happen to be enumerated in +#[test] +fn abi_is_independent_of_file_creation_order() { + let orderings: [&[&str]; 4] = [&["a", "b"], &["b", "a"], &["a", "b"], &["b", "a"]]; + + let artifacts = orderings + .iter() + .map(|order| { + let (_temp, project) = duplicate_names_project(order); + serde_json::to_string_pretty(&generate_abi(&project).expect("generate ABI")) + .expect("serialize ABI") + }) + .collect::>(); + + for artifact in &artifacts[1..] { + assert_eq!( + artifact, &artifacts[0], + "ABI artifact changed with source file creation order" + ); + } +} + +/// A bare name that matches several modules is a hard error, not an arbitrary pick +#[test] +fn ambiguous_bare_struct_name_fails_the_build() { + let (_temp, project) = duplicate_names_project(&["a", "b"]); + let src_dir = project.join("src"); + fs::write( + src_dir.join("lib.rs"), + DUPLICATE_NAMES_ROOT + .replace("config: a::Config", "config: Config") + .replace("config: b::Config", "config: Config") + .replace("config.owner", "config.value"), + ) + .expect("rewrite lib.rs"); + + let error = generate_abi(&project).expect_err("ambiguous struct name should fail"); + let error = error.to_string(); + assert!(error.contains("ambiguous"), "unexpected error: {error}"); + assert!(error.contains("a::Config"), "unexpected error: {error}"); + assert!(error.contains("b::Config"), "unexpected error: {error}"); +} + +// --------------------------------------------------------------------------------------------- +// Selector agreement +// +// The selectors below are hard-coded from an independent implementation (`cast sig`), so they hold +// the router, the JSON ABI and the Solidity interface to the same signature rather than to each +// other. +// --------------------------------------------------------------------------------------------- + +/// Selector the compiled router dispatches on, together with the signature it was hashed from +fn router_method(project: &Path, rust_name: &str) -> (String, String) { + let entry_file = project.join("src").join("lib.rs"); + let source = fs::read_to_string(&entry_file).expect("read crate root"); + let ast = syn::parse_file(&source).expect("parse crate root"); + + #[derive(Default)] + struct RouterImpls(Vec); + impl<'ast> Visit<'ast> for RouterImpls { + fn visit_item_impl(&mut self, node: &'ast ItemImpl) { + if node.attrs.iter().any(|attr| attr.path().is_ident("router")) { + self.0.push(node.clone()); + } + syn::visit::visit_item_impl(self, node); + } + } + + let mut impls = RouterImpls::default(); + impls.visit_file(&ast); + + let registry = StructRegistry::parse_crate(&entry_file).expect("parse structs"); + let resolver = StructResolver::registry(registry); + + for impl_block in impls.0 { + let attr_tokens = match &impl_block + .attrs + .iter() + .find(|attr| attr.path().is_ident("router")) + .expect("router attribute") + .meta + { + syn::Meta::List(list) => list.tokens.clone(), + _ => Default::default(), + }; + + let router = + process_router_with_structs(attr_tokens, impl_block.to_token_stream(), &resolver) + .expect("process router"); + + for method in router.available_methods() { + if method.parsed_signature().rust_name() == rust_name { + let selector = method + .function_id() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + return (method.signature().to_string(), format!("0x{selector}")); + } + } + } + + panic!("method `{rust_name}` not found in any router"); +} + +/// Canonical signature of a published ABI entry, rebuilt straight from the JSON +/// +/// Deliberately independent of the ABI types themselves: this is the expansion any caller would +/// perform on the artifact before hashing it. +fn published_signature(abi: &Abi, name: &str) -> String { + fn canonical(param: &Value) -> String { + let ty = param["type"].as_str().expect("parameter type"); + let (base, suffix) = match ty.find('[') { + Some(index) => ty.split_at(index), + None => (ty, ""), + }; + + if base != "tuple" { + return ty.to_string(); + } + + let components = param["components"] + .as_array() + .expect("tuple parameter without components") + .iter() + .map(canonical) + .collect::>() + .join(","); + + format!("({components}){suffix}") + } + + let entry = abi + .iter() + .find(|entry| entry["name"] == name) + .unwrap_or_else(|| panic!("`{name}` in ABI")); + + let inputs = entry["inputs"] + .as_array() + .expect("inputs") + .iter() + .map(canonical) + .collect::>() + .join(","); + + format!("{name}({inputs})") +} + +/// Every published entry agrees with the router on both the signature and its selector +fn assert_selectors_agree(project: &Path, expected: &[(&str, &str, &str)]) { + let abi = generate_abi(project).expect("generate ABI"); + + for (rust_name, signature, selector) in expected { + let (router_signature, router_selector) = router_method(project, rust_name); + + assert_eq!( + router_signature, *signature, + "router signature of `{rust_name}`" + ); + assert_eq!( + router_selector, *selector, + "router selector of `{rust_name}`" + ); + + let sol_name = signature.split('(').next().expect("function name"); + assert_eq!( + published_signature(&abi, sol_name), + *signature, + "published signature of `{rust_name}`" + ); + } +} + +/// Nested struct parameters hash the same in the router and in the artifacts +#[test] +fn nested_struct_selectors_agree_with_the_router() { + let (_temp, project) = fixture_to_project("nested_struct"); + + assert_selectors_agree( + &project, + &[ + ( + "create_user", + "createUser((address,(string,uint256,bool),uint256))", + "0x01ef28b9", + ), + ( + "submit_order", + "submitOrder((uint256,(address,(string,uint256,bool),uint256),\ + (address,(string,uint256,bool),uint256),(uint256,address,uint256),uint8))", + "0x6d4684cd", + ), + ( + "match_order", + "matchOrder((address,(string,uint256,bool),uint256),\ + (address,(string,uint256,bool),uint256),(uint256,address,uint256))", + "0x47eab435", + ), + ("get_user", "getUser(address)", "0x6f77926b"), + ], + ); + + // The interface a caller compiles against declares the same struct + let abi = generate_abi(&project).expect("generate ABI"); + let interface = generate_interface("Nested", &abi).expect("generate interface"); + assert!( + interface + .contains("function createUser(User calldata user) external returns (address _0);"), + "unexpected interface: {interface}" + ); +} + +/// Struct arrays expand to their components instead of hashing as `tuple[]` +#[test] +fn struct_array_selectors_agree_with_the_router() { + let (_temp, project) = fixture_to_project("array_struct"); + + assert_selectors_agree( + &project, + &[ + ( + "add_pools", + "addPools((address,address,uint256,uint256,uint256)[])", + "0xd252d7cc", + ), + ( + "execute_route", + "executeRoute(((address,address,uint256,uint256,uint256)[],address[],uint256))", + "0xb73fabb0", + ), + ( + "update_reserves", + "updateReserves((address,address,uint256,uint256,uint256)[],uint256[])", + "0xcaf046bf", + ), + ( + "apply_batch_update", + "applyBatchUpdate((((address,address,uint256,uint256,uint256),uint256)[],uint256))", + "0xbc61d897", + ), + ], + ); +} + +/// Module-qualified structs resolve to their own definition on both sides +#[test] +fn module_qualified_struct_selectors_agree_with_the_router() { + let (_temp, project) = duplicate_names_project(&["a", "b"]); + + assert_selectors_agree( + &project, + &[ + ("set_a", "setA((uint256,bool))", "0xb6ea7d04"), + ("set_b", "setB((address,uint256,string))", "0xdc78fda8"), + ], + ); +} + +/// A custom selector that no longer matches the published ABI stops the build +#[test] +fn selector_that_diverges_from_the_abi_fails_the_build() { + let (_temp, project) = duplicate_names_project(&["a", "b"]); + fs::write( + project.join("src").join("lib.rs"), + DUPLICATE_NAMES_ROOT.replace( + " pub fn set_a(", + " #[function_id(\"renameMe((uint256,bool))\")]\n pub fn set_a(", + ), + ) + .expect("rewrite lib.rs"); + + let error = format!( + "{:#}", + generate_abi(&project).expect_err("a router selector the ABI cannot reproduce should fail") + ); + assert!(error.contains("0x410cd56e"), "unexpected error: {error}"); + assert!(error.contains("ABI migration"), "unexpected error: {error}"); +} + #[test] fn direct_impl_constructor() { let (_temp, project) = fixture_to_project("direct_impl_constructor"); diff --git a/crates/build/tests/docker_image_verification.rs b/crates/build/tests/docker_image_verification.rs new file mode 100644 index 000000000..45b7a62ce --- /dev/null +++ b/crates/build/tests/docker_image_verification.rs @@ -0,0 +1,101 @@ +//! End-to-end checks that the build refuses untrusted Docker images. +//! +//! These tests talk to a real Docker daemon and pull a small public image, so they are +//! ignored by default. Run them with: +//! +//! ```text +//! cargo test -p fluentbase-build --test docker_image_verification -- --ignored +//! ``` + +use fluentbase_build::docker::ensure_rust_image; +use std::process::Command; + +/// Small public image used as a stand-in for the build image. +const UPSTREAM: &str = "alpine"; +const UPSTREAM_TAG: &str = "3.20"; +/// Repository the poisoned image pretends to be. +const IMPERSONATED: &str = "ghcr.io/fluentlabs-xyz/fluentbase-build-verification-test"; + +fn docker(args: &[&str]) -> String { + let output = Command::new("docker") + .args(args) + .output() + .unwrap_or_else(|err| panic!("failed to run docker {args:?}: {err}")); + assert!( + output.status.success(), + "docker {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn upstream_reference() -> String { + format!("{UPSTREAM}:{UPSTREAM_TAG}") +} + +/// Pull the upstream image and retag it as the build image, i.e. exactly what an attacker +/// with local Docker access does to get their own code into a build. +fn poison_local_tag(tag: &str) -> String { + let upstream = upstream_reference(); + docker(&["pull", "--platform", "linux/amd64", &upstream]); + docker(&["tag", &upstream, tag]); + docker(&["image", "inspect", "--format", "{{.Id}}", tag]) +} + +#[test] +#[ignore = "requires a Docker daemon and network access"] +fn rejects_poisoned_local_tag() { + let tag = format!("{IMPERSONATED}:v0.0.0-poisoned"); + poison_local_tag(&tag); + + let err = ensure_rust_image(&tag, None, false) + .expect_err("a retagged local image must not be executed"); + let message = format!("{err:#}"); + assert!( + message.contains("no registry digest"), + "unexpected error: {message}" + ); + + // The escape hatch is the only way to run it, and it never applies to a pinned build. + let unverified = + ensure_rust_image(&tag, None, true).expect("verification can be relaxed explicitly"); + assert!(unverified.digest().is_none()); + + let pinned_digest = format!("sha256:{}", "1".repeat(64)); + let err = ensure_rust_image(&tag, Some(&pinned_digest), true) + .expect_err("--allow-unverified-docker-image must not relax a pinned digest"); + let message = format!("{err:#}"); + assert!( + message.contains("digest mismatch") && message.contains(&pinned_digest), + "unexpected error: {message}" + ); + + docker(&["rmi", &tag]); +} + +#[test] +#[ignore = "requires a Docker daemon and network access"] +fn accepts_image_pulled_from_its_repository_and_pins_it() { + let upstream = upstream_reference(); + docker(&["pull", "--platform", "linux/amd64", &upstream]); + + let verified = ensure_rust_image(&upstream, None, false).expect("genuine image is accepted"); + let digest = verified.digest().expect("digest is resolved").to_string(); + + assert_eq!(verified.reference(), format!("{UPSTREAM}@{digest}")); + assert_eq!(verified.requested(), upstream); + + // The same digest, passed explicitly, must be accepted. + let pinned = ensure_rust_image(&upstream, Some(&digest), false).expect("pinned image is run"); + assert_eq!(pinned.reference(), verified.reference()); + assert_eq!(pinned.image_id(), verified.image_id()); + + // Any other digest must fail before the container is started. + let wrong = format!("sha256:{}", "9".repeat(64)); + let err = ensure_rust_image(&upstream, Some(&wrong), false) + .expect_err("a digest that does not match the local image must fail"); + assert!( + format!("{err:#}").contains("digest mismatch"), + "unexpected error: {err:#}" + ); +} diff --git a/crates/codec-derive/src/lib.rs b/crates/codec-derive/src/lib.rs index 4c830bca2..a5f5bbb17 100644 --- a/crates/codec-derive/src/lib.rs +++ b/crates/codec-derive/src/lib.rs @@ -359,6 +359,50 @@ impl CodecStruct { } } + /// Generate the `SolidityEventTopic` implementation used when the struct is an indexed event + /// parameter. + /// + /// A struct is a Solidity reference type, so its topic is a hash over its members concatenated + /// in place -- no length prefixes and no head/tail offsets, unlike ordinary ABI encoding. + fn generate_event_topic_impl(&self) -> TokenStream2 { + let struct_name = &self.struct_name; + let crate_path = Self::get_crate_path(); + let (impl_generics, ty_generics, existing_where) = self.generics.split_for_impl(); + + let mut where_clause = existing_where + .cloned() + .unwrap_or_else(|| parse_quote!(where)); + for field in &self.fields { + let ty = &field.ty; + where_clause + .predicates + .push(parse_quote!(#ty: #crate_path::SolidityEventTopic)); + } + + let encode_fields = self.fields.iter().map(|field| { + let ident = &field.ident; + quote! { + #crate_path::SolidityEventTopic::encode_topic_preimage(&self.#ident, out)?; + } + }); + + quote! { + impl #impl_generics #crate_path::SolidityEventTopic for #struct_name #ty_generics + #where_clause + { + const IS_REFERENCE_TYPE: bool = true; + + fn encode_topic_preimage( + &self, + out: &mut #crate_path::bytes::BytesMut, + ) -> Result<(), #crate_path::CodecError> { + #(#encode_fields)* + Ok(()) + } + } + } + } + /// Generate the complete trait implementation for a specific mode and static/dynamic setting fn generate_impl(&self, sol_mode: bool, is_static: bool) -> TokenStream2 { let struct_name = &self.struct_name; @@ -412,12 +456,14 @@ impl ToTokens for CodecStruct { let sol_impl_dynamic = self.generate_impl(true, false); let wasm_impl_static = self.generate_impl(false, true); let wasm_impl_dynamic = self.generate_impl(false, false); + let event_topic_impl = self.generate_event_topic_impl(); tokens.extend(quote! { #sol_impl_static #sol_impl_dynamic #wasm_impl_static #wasm_impl_dynamic + #event_topic_impl }); } } diff --git a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__empty_struct.snap b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__empty_struct.snap index 3539f1601..32ea9979f 100644 --- a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__empty_struct.snap +++ b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__empty_struct.snap @@ -292,3 +292,12 @@ impl< )) } } +impl ::fluentbase_sdk::codec::SolidityEventTopic for EmptyStruct { + const IS_REFERENCE_TYPE: bool = true; + fn encode_topic_preimage( + &self, + out: &mut ::fluentbase_sdk::codec::bytes::BytesMut, + ) -> Result<(), ::fluentbase_sdk::codec::CodecError> { + Ok(()) + } +} diff --git a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__generic_struct.snap b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__generic_struct.snap index d42a54c3a..390bb93f1 100644 --- a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__generic_struct.snap +++ b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__generic_struct.snap @@ -939,3 +939,25 @@ where )) } } +impl ::fluentbase_sdk::codec::SolidityEventTopic for GenericStruct +where + T: Clone + Default, + T: ::fluentbase_sdk::codec::SolidityEventTopic, + Vec: ::fluentbase_sdk::codec::SolidityEventTopic, +{ + const IS_REFERENCE_TYPE: bool = true; + fn encode_topic_preimage( + &self, + out: &mut ::fluentbase_sdk::codec::bytes::BytesMut, + ) -> Result<(), ::fluentbase_sdk::codec::CodecError> { + ::fluentbase_sdk::codec::SolidityEventTopic::encode_topic_preimage( + &self.field1, + out, + )?; + ::fluentbase_sdk::codec::SolidityEventTopic::encode_topic_preimage( + &self.field2, + out, + )?; + Ok(()) + } +} diff --git a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__simple_struct.snap b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__simple_struct.snap index e0435b8a1..fa7c2480f 100644 --- a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__simple_struct.snap +++ b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__simple_struct.snap @@ -1225,3 +1225,29 @@ where )) } } +impl ::fluentbase_sdk::codec::SolidityEventTopic for TestStruct +where + bool: ::fluentbase_sdk::codec::SolidityEventTopic, + Bytes: ::fluentbase_sdk::codec::SolidityEventTopic, + Vec: ::fluentbase_sdk::codec::SolidityEventTopic, +{ + const IS_REFERENCE_TYPE: bool = true; + fn encode_topic_preimage( + &self, + out: &mut ::fluentbase_sdk::codec::bytes::BytesMut, + ) -> Result<(), ::fluentbase_sdk::codec::CodecError> { + ::fluentbase_sdk::codec::SolidityEventTopic::encode_topic_preimage( + &self.bool_val, + out, + )?; + ::fluentbase_sdk::codec::SolidityEventTopic::encode_topic_preimage( + &self.bytes_val, + out, + )?; + ::fluentbase_sdk::codec::SolidityEventTopic::encode_topic_preimage( + &self.vec_val, + out, + )?; + Ok(()) + } +} diff --git a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__single_field_struct.snap b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__single_field_struct.snap index e2df55df9..9f5d5bef2 100644 --- a/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__single_field_struct.snap +++ b/crates/codec-derive/src/snapshots/fluentbase_codec_derive__tests__single_field_struct.snap @@ -582,3 +582,19 @@ where )) } } +impl ::fluentbase_sdk::codec::SolidityEventTopic for SingleFieldStruct +where + u64: ::fluentbase_sdk::codec::SolidityEventTopic, +{ + const IS_REFERENCE_TYPE: bool = true; + fn encode_topic_preimage( + &self, + out: &mut ::fluentbase_sdk::codec::bytes::BytesMut, + ) -> Result<(), ::fluentbase_sdk::codec::CodecError> { + ::fluentbase_sdk::codec::SolidityEventTopic::encode_topic_preimage( + &self.value, + out, + )?; + Ok(()) + } +} diff --git a/crates/codec/src/bytes_codec.rs b/crates/codec/src/bytes_codec.rs index b14f7bdc5..43d49e3e7 100644 --- a/crates/codec/src/bytes_codec.rs +++ b/crates/codec/src/bytes_codec.rs @@ -1,6 +1,6 @@ use crate::{ alloc::string::ToString, - encoder::{align_up, read_u32_aligned, write_u32_aligned}, + encoder::{align_up, checked_decode_slice, read_u32_aligned, write_u32_aligned}, error::{CodecError, DecodingError}, }; use byteorder::ByteOrder; @@ -112,12 +112,15 @@ pub fn read_bytes( let (data_offset, data_len) = read_bytes_header::(buf, offset)?; let data = if SOL_MODE { - buf.chunk()[data_offset + 32..data_offset + 32 + data_len].to_vec() + let data_start = data_offset + .checked_add(32) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + checked_decode_slice(buf, data_start, data_len, "bytes body exceeds input")? } else { - buf.chunk()[data_offset..data_offset + data_len].to_vec() + checked_decode_slice(buf, data_offset, data_len, "bytes body exceeds input")? }; - Ok(Bytes::from(data)) + Ok(Bytes::copy_from_slice(data)) } /// Reads the header of the bytes data in Solidity or WASM compatible format @@ -138,9 +141,16 @@ pub fn read_bytes_header_wasm( ) -> Result<(usize, usize), CodecError> { let aligned_elem_size = align_up::(mem::size_of::()); - if buffer.remaining() < offset + aligned_elem_size * 2 { + let header_size = aligned_elem_size + .checked_mul(2) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let header_end = offset + .checked_add(header_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + + if buffer.remaining() < header_end { return Err(CodecError::Decoding(DecodingError::BufferTooSmall { - expected: offset + aligned_elem_size * 2, + expected: header_end, found: buffer.remaining(), msg: "buffer too small to read bytes header".to_string(), })); diff --git a/crates/codec/src/encoder.rs b/crates/codec/src/encoder.rs index 1252f3a17..9cd4ed483 100644 --- a/crates/codec/src/encoder.rs +++ b/crates/codec/src/encoder.rs @@ -1,5 +1,8 @@ -use crate::func::FunctionArgs; -use crate::{alloc::string::ToString, error::CodecError}; +use crate::{ + alloc::string::ToString, + error::{CodecError, DecodingError}, + func::FunctionArgs, +}; use byteorder::{ByteOrder, BE, LE}; use bytes::{Buf, BytesMut}; use core::marker::PhantomData; @@ -256,6 +259,72 @@ pub fn read_u32_aligned( } } +/// Returns a contiguous range from a decoder buffer without allowing attacker-controlled offsets +/// or lengths to overflow or panic while slicing. +pub(crate) fn checked_decode_slice<'a>( + buf: &'a impl Buf, + offset: usize, + len: usize, + msg: &'static str, +) -> Result<&'a [u8], CodecError> { + let end = offset + .checked_add(len) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let chunk = buf.chunk(); + + chunk.get(offset..end).ok_or_else(|| { + CodecError::Decoding(DecodingError::BufferTooSmall { + expected: end, + found: chunk.len(), + msg: msg.to_string(), + }) + }) +} + +/// Returns the remaining contiguous data starting at `offset` after validating the offset. +pub(crate) fn checked_decode_slice_from<'a>( + buf: &'a impl Buf, + offset: usize, + msg: &'static str, +) -> Result<&'a [u8], CodecError> { + let chunk = buf.chunk(); + + chunk.get(offset..).ok_or_else(|| { + CodecError::Decoding(DecodingError::BufferTooSmall { + expected: offset, + found: chunk.len(), + msg: msg.to_string(), + }) + }) +} + +/// Checks that a collection's fixed-size element headers fit in its encoded body before any +/// collection allocation is attempted. +pub(crate) fn validate_collection_body( + len: usize, + element_header_size: usize, + body_len: usize, +) -> Result<(), CodecError> { + if len != 0 && element_header_size == 0 { + return Err(CodecError::Decoding(DecodingError::InvalidData( + "non-empty collections of zero-sized elements are not supported".to_string(), + ))); + } + let required = len + .checked_mul(element_header_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + + if body_len < required { + return Err(CodecError::Decoding(DecodingError::BufferTooSmall { + expected: required, + found: body_len, + msg: "collection length exceeds encoded body".to_string(), + })); + } + + Ok(()) +} + /// Returns a mutable slice of the buffer at the specified offset, aligned to the specified /// alignment. This slice is guaranteed to be large enough to hold the value of value_size. pub(crate) fn get_aligned_slice( diff --git a/crates/codec/src/hash.rs b/crates/codec/src/hash.rs index 6895eba28..c0250b4aa 100644 --- a/crates/codec/src/hash.rs +++ b/crates/codec/src/hash.rs @@ -1,6 +1,9 @@ use crate::{ bytes_codec::{read_bytes_header, write_bytes, write_bytes_solidity, write_bytes_wasm}, - encoder::{align_up, read_u32_aligned, write_u32_aligned, Encoder}, + encoder::{ + align_up, checked_decode_slice, checked_decode_slice_from, read_u32_aligned, + validate_collection_body, write_u32_aligned, Encoder, + }, error::{CodecError, DecodingError}, }; use alloc::{format, string::ToString, vec::Vec}; @@ -66,9 +69,13 @@ where let aligned_header_el_size = align_up::(4); let aligned_header_size = align_up::(Self::HEADER_SIZE); - if buf.remaining() < offset + aligned_header_size { + let header_end = offset + .checked_add(aligned_header_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + + if buf.remaining() < header_end { return Err(CodecError::Decoding(DecodingError::BufferTooSmall { - expected: offset + aligned_header_size, + expected: header_end, found: buf.remaining(), msg: "Not enough data to decode HashMap header".to_string(), })); @@ -76,26 +83,45 @@ where let length = read_u32_aligned::(buf, offset)? as usize; + let keys_header_offset = offset + .checked_add(aligned_header_el_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; let (keys_offset, keys_length) = - read_bytes_header::(buf, offset + aligned_header_el_size)?; + read_bytes_header::(buf, keys_header_offset)?; + let values_header_offset = aligned_header_el_size + .checked_mul(3) + .and_then(|header_size| offset.checked_add(header_size)) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; let (values_offset, values_length) = - read_bytes_header::(buf, offset + aligned_header_el_size * 3)?; - - let key_bytes = &buf.chunk()[keys_offset..keys_offset + keys_length]; - let value_bytes = &buf.chunk()[values_offset..values_offset + values_length]; - - let keys = (0..length).map(|i| { - let key_offset = align_up::(K::HEADER_SIZE) * i; - K::decode(&key_bytes, key_offset).unwrap_or_default() - }); - - let values = (0..length).map(|i| { - let value_offset = align_up::(V::HEADER_SIZE) * i; - V::decode(&value_bytes, value_offset).unwrap_or_default() - }); + read_bytes_header::(buf, values_header_offset)?; + + let key_bytes = checked_decode_slice(buf, keys_offset, keys_length, "keys exceed input")?; + let value_bytes = + checked_decode_slice(buf, values_offset, values_length, "values exceed input")?; + let key_header_size = align_up::(K::HEADER_SIZE); + let value_header_size = align_up::(V::HEADER_SIZE); + validate_collection_body(length, key_header_size, key_bytes.len())?; + validate_collection_body(length, value_header_size, value_bytes.len())?; + + let mut result = HashMap::new(); + result.try_reserve(length).map_err(|_| { + CodecError::Decoding(DecodingError::InvalidData( + "unable to reserve map capacity".to_string(), + )) + })?; - let result: HashMap = keys.zip(values).collect(); + for i in 0..length { + let key_offset = key_header_size + .checked_mul(i) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let value_offset = value_header_size + .checked_mul(i) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let key = K::decode(&key_bytes, key_offset)?; + let value = V::decode(&value_bytes, value_offset)?; + result.insert(key, value); + } if result.len() != length { return Err(CodecError::Decoding(DecodingError::InvalidData(format!( @@ -216,9 +242,14 @@ where } // Read relative keys and values offsets (relative to the current offset) - let keys_offset = read_u32_aligned::(buf, start_offset + KEYS_OFFSET)? as usize; - let values_offset = - read_u32_aligned::(buf, start_offset + VALUES_OFFSET)? as usize; + let keys_offset_position = start_offset + .checked_add(KEYS_OFFSET) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let keys_offset = read_u32_aligned::(buf, keys_offset_position)? as usize; + let values_offset_position = start_offset + .checked_add(VALUES_OFFSET) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let values_offset = read_u32_aligned::(buf, values_offset_position)? as usize; // Calculate absolute offsets let keys_start = keys_offset @@ -230,16 +261,32 @@ where .and_then(|sum| sum.checked_add(VALUES_OFFSET)) .ok_or(CodecError::Decoding(DecodingError::Overflow))?; - let mut result = HashMap::with_capacity(length); - - let keys_data = &buf.chunk()[keys_start + 32..]; - let values_data = &buf.chunk()[values_start + 32..]; + let keys_data_start = keys_start + .checked_add(32) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let values_data_start = values_start + .checked_add(32) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let keys_data = checked_decode_slice_from(buf, keys_data_start, "keys body exceeds input")?; + let values_data = + checked_decode_slice_from(buf, values_data_start, "values body exceeds input")?; + let key_header_size = align_up::(K::HEADER_SIZE); + let value_header_size = align_up::(V::HEADER_SIZE); + validate_collection_body(length, key_header_size, keys_data.len())?; + validate_collection_body(length, value_header_size, values_data.len())?; + + let mut result = HashMap::new(); + result.try_reserve(length).map_err(|_| { + CodecError::Decoding(DecodingError::InvalidData( + "unable to reserve map capacity".to_string(), + )) + })?; for i in 0..length { - let key_offset = align_up::(K::HEADER_SIZE) + let key_offset = key_header_size .checked_mul(i) .ok_or(CodecError::Decoding(DecodingError::Overflow))?; - let value_offset = align_up::(V::HEADER_SIZE) + let value_offset = value_header_size .checked_mul(i) .ok_or(CodecError::Decoding(DecodingError::Overflow))?; @@ -319,9 +366,13 @@ where let aligned_offset = align_up::(offset); let aligned_header_size = align_up::(Self::HEADER_SIZE); - if buf.remaining() < aligned_offset + aligned_header_size { + let header_end = aligned_offset + .checked_add(aligned_header_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + + if buf.remaining() < header_end { return Err(CodecError::Decoding(DecodingError::BufferTooSmall { - expected: aligned_offset + aligned_header_size, + expected: header_end, found: buf.remaining(), msg: "Not enough data to decode HashSet header".to_string(), })); @@ -329,15 +380,28 @@ where let length = read_u32_aligned::(buf, aligned_offset)? as usize; + let data_header_offset = aligned_offset + .checked_add(align_up::(4)) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; let (data_offset, data_length) = - read_bytes_header::(buf, aligned_offset + align_up::(4))?; + read_bytes_header::(buf, data_header_offset)?; - let mut result = HashSet::with_capacity(length); + let value_bytes = + checked_decode_slice(buf, data_offset, data_length, "values exceed input")?; + let value_header_size = align_up::(T::HEADER_SIZE); + validate_collection_body(length, value_header_size, value_bytes.len())?; - let value_bytes = &buf.chunk()[data_offset..data_offset + data_length]; + let mut result = HashSet::new(); + result.try_reserve(length).map_err(|_| { + CodecError::Decoding(DecodingError::InvalidData( + "unable to reserve set capacity".to_string(), + )) + })?; for i in 0..length { - let value_offset = align_up::(T::HEADER_SIZE) * i; + let value_offset = value_header_size + .checked_mul(i) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; let value = T::decode(&value_bytes, value_offset)?; result.insert(value); } @@ -447,7 +511,10 @@ where } // Read relative data offset (relative to the current offset) - let values_offset = read_u32_aligned::(buf, start_offset + DATA_OFFSET)? as usize; + let values_offset_position = start_offset + .checked_add(DATA_OFFSET) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let values_offset = read_u32_aligned::(buf, values_offset_position)? as usize; // Calculate absolute offset let values_start = values_offset @@ -455,12 +522,23 @@ where .and_then(|sum| sum.checked_add(DATA_OFFSET)) .ok_or(CodecError::Decoding(DecodingError::Overflow))?; - let mut result = HashSet::with_capacity(length); - - let values_data = &buf.chunk()[values_start + 32..]; + let values_data_start = values_start + .checked_add(32) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let values_data = + checked_decode_slice_from(buf, values_data_start, "values body exceeds input")?; + let value_header_size = align_up::(T::HEADER_SIZE); + validate_collection_body(length, value_header_size, values_data.len())?; + + let mut result = HashSet::new(); + result.try_reserve(length).map_err(|_| { + CodecError::Decoding(DecodingError::InvalidData( + "unable to reserve set capacity".to_string(), + )) + })?; for i in 0..length { - let value_offset = align_up::(T::HEADER_SIZE) + let value_offset = value_header_size .checked_mul(i) .ok_or(CodecError::Decoding(DecodingError::Overflow))?; @@ -504,8 +582,75 @@ mod tests { }; use alloc::vec::Vec; use byteorder::BE; - use bytes::BytesMut; - use hashbrown::HashMap; + use bytes::{Bytes, BytesMut}; + #[test] + fn test_compact_map_rejects_count_larger_than_bodies_before_allocation() { + let encoded = Bytes::from_static(&[ + 0xff, 0xff, 0xff, 0xff, // claimed entry count + 0x14, 0x00, 0x00, 0x00, // keys body offset + 0x00, 0x00, 0x00, 0x00, // keys body length + 0x14, 0x00, 0x00, 0x00, // values body offset + 0x00, 0x00, 0x00, 0x00, // values body length + ]); + + let error = CompactABI::>::decode(&encoded, 0) + .expect_err("a count without key/value headers must fail before reserving capacity"); + + assert!(matches!( + error, + CodecError::Decoding(DecodingError::BufferTooSmall { .. } | DecodingError::Overflow) + )); + } + + #[test] + fn test_compact_set_rejects_count_larger_than_body_before_allocation() { + let encoded = Bytes::from_static(&[ + 0xff, 0xff, 0xff, 0xff, // claimed entry count + 0x0c, 0x00, 0x00, 0x00, // values body offset + 0x00, 0x00, 0x00, 0x00, // values body length + ]); + + let error = CompactABI::>::decode(&encoded, 0) + .expect_err("a count without value headers must fail before reserving capacity"); + + assert!(matches!( + error, + CodecError::Decoding(DecodingError::BufferTooSmall { .. } | DecodingError::Overflow) + )); + } + + #[test] + fn test_solidity_map_rejects_count_larger_than_bodies_before_allocation() { + let mut encoded = BytesMut::zeroed(160); + encoded[28..32].copy_from_slice(&32_u32.to_be_bytes()); + encoded[60..64].copy_from_slice(&u32::MAX.to_be_bytes()); + encoded[92..96].copy_from_slice(&32_u32.to_be_bytes()); + encoded[124..128].copy_from_slice(&32_u32.to_be_bytes()); + + let error = SolidityABI::>::decode(&encoded, 0) + .expect_err("an oversized count must fail before reserving map capacity"); + + assert!(matches!( + error, + CodecError::Decoding(DecodingError::BufferTooSmall { .. } | DecodingError::Overflow) + )); + } + + #[test] + fn test_solidity_set_rejects_count_larger_than_body_before_allocation() { + let mut encoded = BytesMut::zeroed(128); + encoded[28..32].copy_from_slice(&32_u32.to_be_bytes()); + encoded[60..64].copy_from_slice(&u32::MAX.to_be_bytes()); + encoded[92..96].copy_from_slice(&32_u32.to_be_bytes()); + + let error = SolidityABI::>::decode(&encoded, 0) + .expect_err("an oversized count must fail before reserving set capacity"); + + assert!(matches!( + error, + CodecError::Decoding(DecodingError::BufferTooSmall { .. } | DecodingError::Overflow) + )); + } #[test] fn test_nested_map() { diff --git a/crates/codec/src/lib.rs b/crates/codec/src/lib.rs index c439ee09a..19b6a778e 100644 --- a/crates/codec/src/lib.rs +++ b/crates/codec/src/lib.rs @@ -11,6 +11,7 @@ mod evm; mod func; mod hash; mod primitive; +pub mod topic; mod tuple; mod vec; @@ -25,3 +26,4 @@ pub use encoder::*; pub use error::*; #[cfg(feature = "derive")] pub use fluentbase_codec_derive::Codec; +pub use topic::{encode_indexed_topic, IndexedTopic, SolidityEventTopic}; diff --git a/crates/codec/src/primitive.rs b/crates/codec/src/primitive.rs index 7cbe6dde1..07b6b33b9 100644 --- a/crates/codec/src/primitive.rs +++ b/crates/codec/src/primitive.rs @@ -1,6 +1,9 @@ use crate::{ alloc::string::ToString, - encoder::{align_up, get_aligned_indices, get_aligned_slice, is_big_endian, Encoder}, + encoder::{ + align_up, checked_decode_slice, get_aligned_indices, get_aligned_slice, is_big_endian, + Encoder, + }, error::{CodecError, DecodingError}, }; use byteorder::ByteOrder; @@ -51,15 +54,9 @@ impl(>::HEADER_SIZE); - if buf.remaining() < offset + word_size { - return Err(CodecError::Decoding(DecodingError::BufferTooSmall { - expected: offset + word_size, - found: buf.remaining(), - msg: "buf too small to read aligned u8".to_string(), - })); - } + let chunk = + checked_decode_slice(buf, offset, word_size, "buf too small to read aligned u8")?; - let chunk = &buf.chunk()[offset..]; let value = if is_big_endian::() { chunk[word_size - 1] } else { @@ -149,20 +146,17 @@ macro_rules! impl_int { >::HEADER_SIZE, ); - if buf.remaining() < offset + ALIGN { - return Err(CodecError::Decoding(DecodingError::BufferTooSmall { - expected: offset + ALIGN, - found: buf.remaining(), - msg: "buf too small to decode value".to_string(), - })); - } + // The read below spans the whole aligned word, so the buffer has to be checked + // against `word_size` and not `ALIGN`: types wider than the alignment (`u64` and + // `i64` with `ALIGN == 4`) otherwise pass the guard while truncated and panic + // inside the byteorder read. + let chunk = + checked_decode_slice(buf, offset, word_size, "buf too small to decode value")?; - let chunk = &buf.chunk()[offset..]; let value = if is_big_endian::() { B::$read_method( &chunk[word_size - - >::HEADER_SIZE - ..word_size], + - >::HEADER_SIZE..], ) } else { B::$read_method( @@ -667,4 +661,126 @@ mod tests { let decoded = SolidityPackedABI::<[u16; 3]>::decode(&buf, 0).unwrap(); assert_eq!(arr, decoded); } + + /// Encodes `value` with `ALIGN == 4` and asserts that every truncation of the resulting word + /// is rejected with a decoding error instead of panicking, in both byte orders. + macro_rules! assert_truncated_decode_errors { + ($typ:ty, $value:expr) => {{ + const ALIGN: usize = 4; + let value: $typ = $value; + + let mut le_buf = BytesMut::new(); + <$typ as Encoder>::encode(&value, &mut le_buf, 0) + .unwrap(); + let mut be_buf = BytesMut::new(); + <$typ as Encoder>::encode(&value, &mut be_buf, 0) + .unwrap(); + + let word_size = le_buf.len(); + assert_eq!(word_size, align_up::(size_of::<$typ>())); + assert_eq!(be_buf.len(), word_size); + + // A full word decodes back to the original value. + let le_full = le_buf.clone().freeze(); + assert_eq!( + <$typ as Encoder>::decode(&le_full, 0).unwrap(), + value + ); + let be_full = be_buf.clone().freeze(); + assert_eq!( + <$typ as Encoder>::decode(&be_full, 0).unwrap(), + value + ); + + // Every short buffer is an error, including the 4..7 byte range that used to slip + // past the `ALIGN`-sized guard and panic for the 8-byte types. + for len in 0..word_size { + let le_short = Bytes::copy_from_slice(&le_buf[..len]); + assert!( + <$typ as Encoder>::decode(&le_short, 0) + .is_err(), + "LE decode of {} bytes should fail for {}", + len, + stringify!($typ) + ); + + let be_short = Bytes::copy_from_slice(&be_buf[..len]); + assert!( + <$typ as Encoder>::decode(&be_short, 0).is_err(), + "BE decode of {} bytes should fail for {}", + len, + stringify!($typ) + ); + } + }}; + } + + #[test] + fn test_truncated_native_widths_do_not_panic() { + assert_truncated_decode_errors!(u8, 0xAB); + assert_truncated_decode_errors!(u16, 0xABCD); + assert_truncated_decode_errors!(u32, 0xABCDEF01); + assert_truncated_decode_errors!(u64, 0x0123456789ABCDEF); + assert_truncated_decode_errors!(i16, -0x1234); + assert_truncated_decode_errors!(i32, -0x12345678); + assert_truncated_decode_errors!(i64, -0x123456789ABCDEF); + } + + #[test] + fn test_truncated_decode_reports_full_word() { + // A 4-byte buffer satisfies `ALIGN` but not the 8-byte `u64` word. + let short = Bytes::from_static(&[0xFF, 0xFF, 0xFF, 0xFF]); + let err = >::decode(&short, 0).unwrap_err(); + + match err { + CodecError::Decoding(DecodingError::BufferTooSmall { + expected, found, .. + }) => { + assert_eq!(expected, 8); + assert_eq!(found, 4); + } + other => panic!("unexpected error: {:?}", other), + } + } + + #[test] + fn test_decode_offset_overflow_is_rejected() { + let buf = Bytes::from_static(&[0u8; 8]); + assert!(>::decode(&buf, usize::MAX).is_err()); + } + + /// Encodes three `u64`s back to back at `ALIGN == 4` and checks that each one round-trips at + /// its own offset, and that a buffer missing the last byte errors instead of panicking. + macro_rules! assert_multi_value_u64_vector { + ($byte_order:ty) => {{ + const ALIGN: usize = 4; + let values: [u64; 3] = [0, 0x0123456789ABCDEF, u64::MAX]; + + let mut buf = BytesMut::new(); + for (i, value) in values.iter().enumerate() { + >::encode(value, &mut buf, i * 8) + .unwrap(); + } + assert_eq!(buf.len(), 24); + let encoded = buf.freeze(); + + for (i, expected) in values.iter().enumerate() { + let decoded = + >::decode(&encoded, i * 8) + .unwrap(); + assert_eq!(decoded, *expected, "element {} round-trip", i); + } + + let truncated = encoded.slice(..23); + assert!( + >::decode(&truncated, 16).is_err() + ); + }}; + } + + #[test] + fn test_aligned_multi_value_u64_vector_decodes() { + assert_multi_value_u64_vector!(LittleEndian); + assert_multi_value_u64_vector!(BigEndian); + } } diff --git a/crates/codec/src/topic.rs b/crates/codec/src/topic.rs new file mode 100644 index 000000000..0cfd5ce7a --- /dev/null +++ b/crates/codec/src/topic.rs @@ -0,0 +1,241 @@ +//! Solidity's encoding of indexed event parameters. +//! +//! An indexed parameter of a value type occupies its topic word directly. Reference types -- +//! `string`, `bytes`, arrays (both fixed and dynamic) and structs -- do not fit in a word, so the +//! topic is `keccak256` of a preimage that is *not* ordinary ABI encoding: it carries no length +//! prefix and no head/tail offsets, and every member sits in place, padded to a whole number of +//! words. +//! +//! Hashing is left to the caller, because contracts reach keccak256 through a host function; this +//! module only builds the bytes to hash. See the [Solidity ABI specification][spec]. +//! +//! [spec]: https://docs.soliditylang.org/en/latest/abi-spec.html#encoding-of-indexed-event-parameters + +use crate::{ + encoder::Encoder, + error::{CodecError, EncodingError}, +}; +use alloc::{format, string::String, vec::Vec}; +use alloy_primitives::{Address, Bytes, FixedBytes, Signed, Uint}; +use byteorder::BE; +use bytes::BytesMut; + +/// Width of an ABI word, and therefore of a topic. +const WORD: usize = 32; + +/// Solidity's indexed-event encoding, which departs from ordinary ABI encoding for every type +/// that is not a value type. +pub trait SolidityEventTopic { + /// `true` for reference types, whose topic is `keccak256` of the encoding produced here; + /// `false` for value types, whose encoding *is* the topic word. + const IS_REFERENCE_TYPE: bool; + + /// Appends this value's encoding as a member of an array or struct. + /// + /// Members always occupy a whole number of words, so this is the encoding Solidity calls the + /// topic preimage. + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError>; + + /// Appends the bytes whose `keccak256` is the topic of a top-level indexed parameter -- or, + /// for a value type, the topic word itself. + /// + /// Identical to [`Self::encode_topic_preimage`] except for `bytes` and `string`, which are + /// hashed over their raw contents when indexed directly. + fn encode_topic_input(&self, out: &mut BytesMut) -> Result<(), CodecError> { + self.encode_topic_preimage(out) + } +} + +/// The topic contributed by a single indexed event parameter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IndexedTopic { + /// A value type: this word is the topic. + Word([u8; WORD]), + /// A reference type: the topic is `keccak256` of these bytes. + Preimage(BytesMut), +} + +/// Encodes one indexed event parameter the way Solidity does. +/// +/// Reference types come back as a preimage rather than a topic because the caller owns the choice +/// of keccak256 implementation. +pub fn encode_indexed_topic(value: &T) -> Result { + let mut out = BytesMut::new(); + value.encode_topic_input(&mut out)?; + + if T::IS_REFERENCE_TYPE { + return Ok(IndexedTopic::Preimage(out)); + } + + let word: [u8; WORD] = out.as_ref().try_into().map_err(|_| { + CodecError::Encoding(EncodingError::InvalidInputData(format!( + "an indexed value type must encode to exactly {} bytes, got {}", + WORD, + out.len() + ))) + })?; + + Ok(IndexedTopic::Word(word)) +} + +/// Writes the 32-byte ABI word of a value type. +/// +/// Solidity encodes a value type identically in a topic and in the data section, so this reuses +/// the encoder that writes the data section instead of restating the padding rules. +fn write_value_word(value: &T, out: &mut BytesMut) -> Result<(), CodecError> +where + T: Encoder, +{ + let mut word = BytesMut::new(); + value.encode(&mut word, 0)?; + out.extend_from_slice(&word); + Ok(()) +} + +/// Padding that brings a `bytes`/`string` member up to a whole number of words. +/// +/// An empty member still occupies one zero word rather than disappearing. +const fn bytes_member_padding(len: usize) -> usize { + if len == 0 { + return WORD; + } + + match len % WORD { + 0 => 0, + rest => WORD - rest, + } +} + +fn write_bytes_member(value: &[u8], out: &mut BytesMut) { + out.extend_from_slice(value); + out.resize(out.len() + bytes_member_padding(value.len()), 0); +} + +macro_rules! impl_value_type { + ($($ty:ty),* $(,)?) => { + $( + impl SolidityEventTopic for $ty { + const IS_REFERENCE_TYPE: bool = false; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + write_value_word(self, out) + } + } + )* + }; +} + +impl_value_type!(bool, u8, u16, u32, u64, i16, i32, i64, Address); + +impl SolidityEventTopic for FixedBytes { + const IS_REFERENCE_TYPE: bool = false; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + write_value_word(self, out) + } +} + +impl SolidityEventTopic for Uint { + const IS_REFERENCE_TYPE: bool = false; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + write_value_word(self, out) + } +} + +impl SolidityEventTopic for Signed { + const IS_REFERENCE_TYPE: bool = false; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + write_value_word(self, out) + } +} + +impl SolidityEventTopic for Bytes { + const IS_REFERENCE_TYPE: bool = true; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + write_bytes_member(self.as_ref(), out); + Ok(()) + } + + fn encode_topic_input(&self, out: &mut BytesMut) -> Result<(), CodecError> { + out.extend_from_slice(self.as_ref()); + Ok(()) + } +} + +impl SolidityEventTopic for String { + const IS_REFERENCE_TYPE: bool = true; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + write_bytes_member(self.as_bytes(), out); + Ok(()) + } + + fn encode_topic_input(&self, out: &mut BytesMut) -> Result<(), CodecError> { + out.extend_from_slice(self.as_bytes()); + Ok(()) + } +} + +/// Arrays drop their length prefix entirely and simply concatenate their members. +impl SolidityEventTopic for Vec { + const IS_REFERENCE_TYPE: bool = true; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + for element in self.iter() { + element.encode_topic_preimage(out)?; + } + Ok(()) + } +} + +impl SolidityEventTopic for [T; N] { + const IS_REFERENCE_TYPE: bool = true; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + for element in self.iter() { + element.encode_topic_preimage(out)?; + } + Ok(()) + } +} + +/// Tuples follow the struct rule: their members are concatenated in place, so a nested dynamic +/// member is inlined rather than pointed at by an offset. +macro_rules! impl_tuple { + ($($ty:ident),+) => { + #[allow(non_snake_case)] + impl<$($ty: SolidityEventTopic,)+> SolidityEventTopic for ($($ty,)+) { + const IS_REFERENCE_TYPE: bool = true; + + fn encode_topic_preimage(&self, out: &mut BytesMut) -> Result<(), CodecError> { + let ($($ty,)+) = self; + $($ty.encode_topic_preimage(out)?;)+ + Ok(()) + } + } + }; +} + +impl SolidityEventTopic for () { + const IS_REFERENCE_TYPE: bool = true; + + fn encode_topic_preimage(&self, _out: &mut BytesMut) -> Result<(), CodecError> { + Ok(()) + } +} + +impl_tuple!(T1); +impl_tuple!(T1, T2); +impl_tuple!(T1, T2, T3); +impl_tuple!(T1, T2, T3, T4); +impl_tuple!(T1, T2, T3, T4, T5); +impl_tuple!(T1, T2, T3, T4, T5, T6); +impl_tuple!(T1, T2, T3, T4, T5, T6, T7); +impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8); +impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9); +impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10); +impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11); +impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12); diff --git a/crates/codec/src/vec.rs b/crates/codec/src/vec.rs index 3ef8d5279..498efe0ee 100644 --- a/crates/codec/src/vec.rs +++ b/crates/codec/src/vec.rs @@ -1,7 +1,10 @@ use crate::{ alloc::string::ToString, bytes_codec::{read_bytes, read_bytes_header, write_bytes_solidity, write_bytes_wasm}, - encoder::{align_up, read_u32_aligned, write_u32_aligned, Encoder}, + encoder::{ + align_up, checked_decode_slice_from, read_u32_aligned, validate_collection_body, + write_u32_aligned, Encoder, + }, error::{CodecError, DecodingError}, }; use alloc::vec::Vec; @@ -70,9 +73,13 @@ where fn decode(buf: &impl Buf, offset: usize) -> Result { let aligned_header_el_size = align_up::(4); - if buf.remaining() < offset + aligned_header_el_size { + let header_end = offset + .checked_add(aligned_header_el_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + + if buf.remaining() < header_end { return Err(CodecError::Decoding(DecodingError::BufferTooSmall { - expected: offset + aligned_header_el_size, + expected: header_end, found: buf.remaining(), msg: "failed to decode vector length".to_string(), })); @@ -83,11 +90,21 @@ where return Ok(Vec::new()); } - let mut result = Vec::with_capacity(data_len); let data = read_bytes::(buf, offset + aligned_header_el_size)?; + let element_header_size = align_up::(T::HEADER_SIZE); + validate_collection_body(data_len, element_header_size, data.len())?; + + let mut result = Vec::new(); + result.try_reserve(data_len).map_err(|_| { + CodecError::Decoding(DecodingError::InvalidData( + "unable to reserve vector capacity".to_string(), + )) + })?; for i in 0..data_len { - let elem_offset = i * align_up::(T::HEADER_SIZE); + let elem_offset = i + .checked_mul(element_header_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; let value = T::decode(&data, elem_offset)?; result.push(value); } @@ -144,11 +161,24 @@ where return Ok(Vec::new()); } - let mut result = Vec::with_capacity(data_len); - let chunk = &buf.chunk()[(data_offset + 32) as usize..]; + let body_offset = (data_offset as usize) + .checked_add(32) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; + let chunk = checked_decode_slice_from(buf, body_offset, "vector body exceeds input")?; + let element_header_size = align_up::(T::HEADER_SIZE); + validate_collection_body(data_len, element_header_size, chunk.len())?; + + let mut result = Vec::new(); + result.try_reserve(data_len).map_err(|_| { + CodecError::Decoding(DecodingError::InvalidData( + "unable to reserve vector capacity".to_string(), + )) + })?; for i in 0..data_len { - let elem_offset = i * align_up::(T::HEADER_SIZE); + let elem_offset = i + .checked_mul(element_header_size) + .ok_or(CodecError::Decoding(DecodingError::Overflow))?; let value = T::decode(&chunk, elem_offset)?; result.push(value); } @@ -233,6 +263,69 @@ mod tests { assert_eq!(original, decoded); } + #[test] + fn test_compact_vec_rejects_count_larger_than_body_before_allocation() { + let encoded = Bytes::from_static(&[ + 0xff, 0xff, 0xff, 0xff, // claimed element count + 0x0c, 0x00, 0x00, 0x00, // body offset + 0x00, 0x00, 0x00, 0x00, // body length + ]); + + let error = as Encoder>::decode(&encoded, 0) + .expect_err("a count without element headers must fail before reserving capacity"); + + assert!(matches!( + error, + CodecError::Decoding(DecodingError::BufferTooSmall { .. } | DecodingError::Overflow) + )); + + let one_element_short = Bytes::from_static(&[ + 0x02, 0x00, 0x00, 0x00, // two elements claimed + 0x0c, 0x00, 0x00, 0x00, // body offset + 0x04, 0x00, 0x00, 0x00, // one u32 body + 0x00, 0x00, 0x00, 0x00, + ]); + as Encoder>::decode(&one_element_short, 0) + .expect_err("a count one larger than body capacity must be rejected"); + } + + #[test] + fn test_solidity_vec_rejects_count_larger_than_body_before_allocation() { + let mut encoded = BytesMut::zeroed(64); + encoded[28..32].copy_from_slice(&32_u32.to_be_bytes()); + encoded[60..64].copy_from_slice(&u32::MAX.to_be_bytes()); + + let error = as Encoder>::decode(&encoded, 0) + .expect_err("a count without element headers must fail before reserving capacity"); + + assert!(matches!( + error, + CodecError::Decoding(DecodingError::BufferTooSmall { .. } | DecodingError::Overflow) + )); + + let mut one_element_short = BytesMut::zeroed(96); + one_element_short[28..32].copy_from_slice(&32_u32.to_be_bytes()); + one_element_short[60..64].copy_from_slice(&2_u32.to_be_bytes()); + as Encoder>::decode(&one_element_short, 0) + .expect_err("a Solidity count one larger than body capacity must be rejected"); + } + + #[test] + fn test_non_empty_zero_sized_vector_is_rejected() { + let encoded = Bytes::from_static(&[ + 0x01, 0x00, 0x00, 0x00, // one zero-sized element claimed + 0x0c, 0x00, 0x00, 0x00, // body offset + 0x00, 0x00, 0x00, 0x00, // empty body + ]); + + let error = as Encoder>::decode(&encoded, 0) + .expect_err("a zero-byte body must not authorize an attacker-controlled decode loop"); + assert!(matches!( + error, + CodecError::Decoding(DecodingError::InvalidData(_)) + )); + } + #[test] fn test_nested_vec_le_a2() { let original: Vec> = vec![vec![3, 4], vec![5, 6, 7]]; diff --git a/crates/codec/tests/topic.rs b/crates/codec/tests/topic.rs new file mode 100644 index 000000000..7ca7dec6e --- /dev/null +++ b/crates/codec/tests/topic.rs @@ -0,0 +1,299 @@ +//! Indexed event topics, checked against `alloy-sol-types` as an independent implementation of +//! the Solidity ABI's indexed-parameter encoding, plus hand-written vectors for the shapes that +//! ordinary ABI encoding gets wrong (dynamic values, fixed arrays, structs and nesting). + +use alloy_primitives::{keccak256, Address, Bytes, FixedBytes, B256, I256, U256}; +use alloy_sol_types::{sol_data, EventTopic, SolType}; +use fluentbase_codec::{encode_indexed_topic, Codec, IndexedTopic, SolidityEventTopic}; + +/// The topic a contract would put in the log: the word itself for value types, the hash of the +/// preimage for reference types. +fn topic(value: &T) -> B256 { + match encode_indexed_topic(value).expect("encode topic") { + IndexedTopic::Word(word) => B256::new(word), + IndexedTopic::Preimage(preimage) => keccak256(&preimage), + } +} + +/// The topic `alloy-sol-types` produces for the same value. +fn expected(value: &S::RustType) -> B256 +where + S: SolType + EventTopic, +{ + S::encode_topic(value).0 +} + +fn preimage(value: &T) -> Vec { + match encode_indexed_topic(value).expect("encode topic") { + IndexedTopic::Word(word) => word.to_vec(), + IndexedTopic::Preimage(preimage) => preimage.to_vec(), + } +} + +#[test] +fn value_types_occupy_the_topic_word_directly() { + let address = Address::repeat_byte(0xab); + assert_eq!(topic(&address), expected::(&address)); + assert!(matches!( + encode_indexed_topic(&address).unwrap(), + IndexedTopic::Word(_) + )); + + assert_eq!(topic(&true), expected::(&true)); + + let value = U256::from(0xdead_beef_u64); + assert_eq!(topic(&value), expected::>(&value)); + + // A narrow uint is left-padded into the word just like uint256. + let small = alloy_primitives::aliases::U8::from(7u8); + assert_eq!(topic(&small), expected::>(&7u8)); + assert_eq!(topic(&7u8), expected::>(&7u8)); + + assert_eq!(topic(&7u64), expected::>(&7u64)); + + // Negative values are sign-extended across the padding rather than zero-padded. + let negative = I256::unchecked_from(-1234i64); + assert_eq!(topic(&negative), expected::>(&negative)); + + let word = FixedBytes::<32>::repeat_byte(0x11); + assert_eq!(topic(&word), expected::>(&word)); + + // bytesN is padded on the right, unlike every numeric type. + let short = FixedBytes::<4>::new([1, 2, 3, 4]); + assert_eq!(topic(&short), expected::>(&short)); + assert_eq!( + preimage(&short), + hex::decode("0102030400000000000000000000000000000000000000000000000000000000").unwrap() + ); +} + +#[test] +fn indexed_string_hashes_its_raw_contents() { + let value = "hello".to_string(); + + // Not `keccak256(abi.encode("hello"))`: no offset word, no length word, no padding. + assert_eq!(preimage(&value), b"hello"); + assert_eq!(topic(&value), keccak256("hello")); + assert_eq!(topic(&value), expected::(&value)); +} + +#[test] +fn indexed_bytes_hashes_its_raw_contents() { + let value = Bytes::from_static(&[1, 2, 3]); + + assert_eq!(preimage(&value), [1, 2, 3]); + assert_eq!(topic(&value), keccak256([1, 2, 3])); + assert_eq!(topic(&value), expected::(&value)); +} + +#[test] +fn indexed_empty_bytes_hashes_nothing_at_all() { + let value = Bytes::new(); + + assert!(preimage(&value).is_empty()); + assert_eq!(topic(&value), keccak256([])); + assert_eq!(topic(&value), expected::(&value)); +} + +#[test] +fn indexed_dynamic_array_drops_its_length_prefix() { + let values = vec![U256::from(1), U256::from(2)]; + + // Ordinary ABI encoding would start with an offset and a length word; the topic preimage is + // just the elements. + assert_eq!( + hex::encode(preimage(&values)), + concat!( + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000002", + ) + ); + assert_eq!( + topic(&values), + expected::>>(&values) + ); +} + +#[test] +fn indexed_empty_dynamic_array_hashes_an_empty_preimage() { + let values: Vec = Vec::new(); + + assert!(preimage(&values).is_empty()); + assert_eq!( + topic(&values), + expected::>>(&values) + ); +} + +#[test] +fn indexed_fixed_array_is_hashed_rather_than_inlined() { + let values = [Address::repeat_byte(1), Address::repeat_byte(2)]; + + // A fixed array is static, so ordinary ABI encoding leaves it in place; as an indexed + // parameter it is still a reference type and gets hashed. + assert!(matches!( + encode_indexed_topic(&values).unwrap(), + IndexedTopic::Preimage(_) + )); + assert_ne!(topic(&values), B256::from_slice(&preimage(&values)[..32])); + assert_eq!( + topic(&values), + expected::>(&values) + ); +} + +#[test] +fn array_members_that_are_dynamic_are_padded_in_place() { + let values = vec!["a".to_string(), "bc".to_string()]; + + // Each element is padded up to a word; the offsets ordinary ABI encoding would emit are + // absent. + assert_eq!( + hex::encode(preimage(&values)), + concat!( + "6100000000000000000000000000000000000000000000000000000000000000", + "6263000000000000000000000000000000000000000000000000000000000000", + ) + ); + assert_eq!( + topic(&values), + expected::>(&values) + ); +} + +#[test] +fn an_empty_string_member_still_occupies_a_word() { + let values = vec![String::new(), "a".to_string()]; + + assert_eq!(preimage(&values).len(), 64); + assert_eq!( + topic(&values), + expected::>(&values) + ); +} + +#[test] +fn nested_arrays_are_flattened_without_offsets() { + let values = vec![ + vec![U256::from(1), U256::from(2)], + vec![U256::from(3)], + Vec::new(), + ]; + + assert_eq!(preimage(&values).len(), 96); + assert_eq!( + topic(&values), + expected::>>>(&values) + ); +} + +#[derive(Codec, Default, Debug, PartialEq)] +struct Point { + x: U256, + y: U256, +} + +#[derive(Codec, Default, Debug, PartialEq)] +struct Label { + id: U256, + name: String, + tags: Vec, +} + +type SolPoint = (sol_data::Uint<256>, sol_data::Uint<256>); +type SolLabel = ( + sol_data::Uint<256>, + sol_data::String, + sol_data::Array, +); + +#[test] +fn indexed_static_struct_is_hashed_not_truncated_to_its_first_word() { + let point = Point { + x: U256::from(1), + y: U256::from(2), + }; + + // The old encoding copied the first word, so every point sharing an `x` collided. + assert_ne!(topic(&point), B256::left_padding_from(&[1])); + assert_eq!( + topic(&point), + expected::(&(U256::from(1), U256::from(2))) + ); +} + +#[test] +fn indexed_dynamic_struct_inlines_its_members() { + let label = Label { + id: U256::from(9), + name: "fluent".to_string(), + tags: vec![Bytes::from_static(b"a"), Bytes::from_static(b"bb")], + }; + + // Every member sits in place, padded up to a word: no offset words, no length words. + assert_eq!( + hex::encode(preimage(&label)), + concat!( + "0000000000000000000000000000000000000000000000000000000000000009", + "666c75656e740000000000000000000000000000000000000000000000000000", + "6100000000000000000000000000000000000000000000000000000000000000", + "6262000000000000000000000000000000000000000000000000000000000000", + ) + ); + assert_eq!( + topic(&label), + expected::(&( + U256::from(9), + "fluent".to_string(), + vec![Bytes::from_static(b"a"), Bytes::from_static(b"bb")], + )) + ); +} + +#[derive(Codec, Default, Debug, PartialEq)] +struct Nested { + point: Point, + points: Vec, +} + +#[test] +fn nested_structs_recurse_with_the_same_rule() { + let nested = Nested { + point: Point { + x: U256::from(1), + y: U256::from(2), + }, + points: vec![ + Point { + x: U256::from(3), + y: U256::from(4), + }, + Point { + x: U256::from(5), + y: U256::from(6), + }, + ], + }; + + assert_eq!(preimage(&nested).len(), 6 * 32); + assert_eq!( + topic(&nested), + expected::<(SolPoint, sol_data::Array)>(&( + (U256::from(1), U256::from(2)), + vec![ + (U256::from(3), U256::from(4)), + (U256::from(5), U256::from(6)), + ], + )) + ); +} + +#[test] +fn tuples_follow_the_struct_rule() { + let value = (U256::from(1), "x".to_string()); + + assert_eq!( + topic(&value), + expected::<(sol_data::Uint<256>, sol_data::String)>(&(U256::from(1), "x".to_string())) + ); +} diff --git a/crates/contracts/build.rs b/crates/contracts/build.rs index 0a2994851..11a46839c 100644 --- a/crates/contracts/build.rs +++ b/crates/contracts/build.rs @@ -1,5 +1,8 @@ use cargo_metadata::{CrateType, MetadataCommand, Package, PackageId, TargetKind}; -use fluentbase_build::{docker, BuildArgs, DEFAULT_DOCKER_IMAGE, DEFAULT_DOCKER_TAG}; +use fluentbase_build::{ + docker, BuildArgs, DEFAULT_DOCKER_IMAGE, DEFAULT_DOCKER_TAG, ENV_ALLOW_UNVERIFIED_IMAGE, + ENV_DOCKER_DIGEST, +}; use std::{ collections::HashSet, env, fs, @@ -63,6 +66,9 @@ fn contracts_build_args(fluentbase_root_dir: &Path) -> BuildArgs { docker_tag: env::var("FLUENTBASE_BUILD_DOCKER_TAG") .unwrap_or_else(|_| DEFAULT_DOCKER_TAG.to_string()), mount_dir: Some(mount_dir), + // `docker_digest` / `allow_unverified_docker_image` are inherited from + // `BuildArgs::default()`, which reads FLUENTBASE_BUILD_DOCKER_DIGEST and + // FLUENTBASE_BUILD_ALLOW_UNVERIFIED_IMAGE. features, no_default_features: true, locked: true, @@ -201,9 +207,12 @@ fn run_cargo_build( }; if build_args.docker { - let image_ref = format!("{}:{}", build_args.docker_image, build_args.docker_tag); - let image = docker::ensure_rust_image(&image_ref) - .unwrap_or_else(|_| panic!("failed to ensure docker image {image_ref}")); + let image = build_args.ensure_docker_image().unwrap_or_else(|err| { + panic!( + "failed to verify docker image {}: {err:#}", + build_args.docker_image_reference() + ) + }); let rust_toolchain = build_args.toolchain_version(work_dir); @@ -249,6 +258,8 @@ fn main() { println!("cargo:rerun-if-env-changed=FLUENTBASE_CONTRACTS_DOCKER"); println!("cargo:rerun-if-env-changed=FLUENTBASE_BUILD_DOCKER_IMAGE"); println!("cargo:rerun-if-env-changed=FLUENTBASE_BUILD_DOCKER_TAG"); + println!("cargo:rerun-if-env-changed={}", ENV_DOCKER_DIGEST); + println!("cargo:rerun-if-env-changed={}", ENV_ALLOW_UNVERIFIED_IMAGE); println!("cargo:rerun-if-env-changed=FLUENTBASE_CONTRACTS_IGNORE_DEFAULT_RUST_FLAGS"); let fluentbase_root_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("../.."); diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 0aea7e8e7..7008e32c6 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -3,6 +3,28 @@ //! EthVM executes analyzed EVM bytecode and yields on host-bound opcodes //! (calls, storage, logs, etc.). The surrounding runtime performs the //! operation, and the VM resumes with identical EVM semantics and gas. +//! +//! # Why this interpreter is pinned to a single hardfork +//! +//! Everything here runs at `SpecId::OSAKA` unconditionally, and it is *not* a bug that the +//! chain's active fork is ignored: the delegated EVM is versioned by contract upgrade, not by +//! hardfork. +//! +//! This crate is compiled into the EVM runtime contract (`contracts/evm`), which lives at +//! `PRECOMPILE_EVM_RUNTIME` as ordinary rWASM genesis code. Its bytecode is replaced through +//! `contracts/runtime-upgrade`, so EVM semantics can be changed on a live chain in a forkless +//! manner — deploy a new runtime, and every account delegating to it changes behavior at once. +//! The deployed runtime version *is* the fork boundary. +//! +//! The consequence is deliberate and should not be "fixed": an opcode from a newer hardfork, +//! e.g. `CLZ` (EIP-7939, Osaka), executes even while the chain spec still declares an older +//! fork such as Prague. Fork conditions in the chain spec (`crates/node/src/chainspec.rs`) gate +//! the protocol and native REVM side; they intentionally do not reach into delegated bytecode. +//! Threading a `SpecId` through the shared context to gate opcodes here would reintroduce +//! hardfork coupling that the upgrade mechanism exists to avoid. +//! +//! When bumping this, bump [`crate::evm_gas_params`] to match — opcode availability and the gas +//! schedule must describe the same fork. use crate::{ bytecode::AnalyzedBytecode, host::HostWrapperImpl, @@ -64,6 +86,9 @@ impl EthVM { input: inputs_impl, runtime_flag: RuntimeFlags { is_static, + // Intentionally pinned rather than read from the chain's active fork: this + // runtime is upgraded as a contract, not activated by a hardfork. See the + // module docs before "fixing" this to follow the chain spec. spec_id: SpecId::OSAKA, }, extend: InterruptionExtension { diff --git a/crates/evm/src/host.rs b/crates/evm/src/host.rs index 942c134fe..4ed7834b8 100644 --- a/crates/evm/src/host.rs +++ b/crates/evm/src/host.rs @@ -55,6 +55,9 @@ impl<'a, SDK: SystemAPI> Host for HostWrapperImpl<'a, SDK> { self.sdk.context().block_base_fee() } + /// Always zero: Fluent has no blob transactions. + /// + /// See [`Self::blob_hash`] for why this is intended rather than a missing context field. fn blob_gasprice(&self) -> U256 { U256::ZERO } @@ -95,6 +98,17 @@ impl<'a, SDK: SystemAPI> Host for HostWrapperImpl<'a, SDK> { self.sdk.context().tx_origin() } + /// Always zero: Fluent has no blob transactions. + /// + /// EIP-4844 is not supported by the chain — blocks carry no `excess_blob_gas` / `blob_gas_used` + /// and the blob schedule is empty (`crates/genesis/build.rs`), so a type-3 transaction can + /// never be included. Every transaction therefore has an empty versioned-hash list, and + /// canonical EVM semantics for `BLOBHASH` with an out-of-range index are exactly this: push + /// zero. `BLOBBASEFEE` ([`Self::blob_gasprice`]) is zero for the same reason. + /// + /// This is why the shared context carries no blob fields (see [`fluentbase_sdk::TxContextV1`]): + /// there is no value to plumb through. If Fluent ever gains blob transactions, both methods and + /// the context must be extended together. fn blob_hash(&self, _number: usize) -> Option { Some(U256::ZERO) } diff --git a/crates/evm/src/opcodes.rs b/crates/evm/src/opcodes.rs index af03a98e6..df4aeacd9 100644 --- a/crates/evm/src/opcodes.rs +++ b/crates/evm/src/opcodes.rs @@ -147,6 +147,12 @@ fn extcodecopy< ); let code = interruption_outcome.output; let len = as_usize_or_fail!(context.interpreter, len_u256); + // A zero length ignores the memory offset, so it must not be narrowed here + // either, otherwise we'd reject an offset the pre-interruption phase accepted. + if len == 0 { + return; + } + // Non-zero length means the offset was already validated before the interruption. let memory_offset_usize = as_usize_or_fail!(context.interpreter, memory_offset); context .interpreter @@ -548,9 +554,15 @@ fn get_memory_output_range< interpreter, None ); - let out_offset = as_usize_or_fail_ret!(interpreter, out_offset, None); let out_len = as_usize_or_fail_ret!(interpreter, out_len, None); - Some(out_offset..out_offset + out_len) + if out_len == 0 { + // Same sentinel `resize_memory` returns for an empty range: the offset is + // ignored, so narrowing it here would reject an otherwise valid output range. + return Some(usize::MAX..usize::MAX); + } + // Non-zero length means [get_memory_input_range] already validated and resized this range. + let out_offset = as_usize_or_fail_ret!(interpreter, out_offset, None); + Some(out_offset..out_offset.saturating_add(out_len)) } fn call< diff --git a/crates/evm/src/utils.rs b/crates/evm/src/utils.rs index a39bcb96a..8821ea66c 100644 --- a/crates/evm/src/utils.rs +++ b/crates/evm/src/utils.rs @@ -10,6 +10,13 @@ use revm_interpreter::{ }; use revm_primitives::hardfork::SpecId; +/// Gas schedule used by the delegated EVM runtime. +/// +/// A single process-wide table is correct here precisely because the fork is pinned: the runtime +/// always executes at `SpecId::OSAKA` regardless of the chain's active fork, since it is versioned +/// by contract upgrade rather than by hardfork (see the `crate::evm` module docs). If the pin in +/// `EthVM::new` ever changes, change it here too — opcode availability and gas prices must +/// describe the same fork. pub fn evm_gas_params() -> &'static GasParams { static GAS_PARAMS: OnceBox = OnceBox::new(); GAS_PARAMS.get_or_init(|| GasParams::new_spec(SpecId::OSAKA).into()) diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 4b3bb7b59..c4670668f 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -57,6 +57,7 @@ fluentbase-runtime = { workspace = true } fluentbase-revm = { workspace = true } fluentbase-genesis = { workspace = true } fluentbase-types = { workspace = true } +fluentbase-release-verify = { workspace = true, features = ["reqwest"] } # alloy alloy-primitives = { workspace = true } @@ -79,7 +80,6 @@ serde_json = { workspace = true } eyre = { workspace = true } directories = { workspace = true } flate2 = { workspace = true } -reqwest = { workspace = true } [features] default = ["std"] diff --git a/crates/node/src/chainspec.rs b/crates/node/src/chainspec.rs index a09c17a4a..46b13084d 100644 --- a/crates/node/src/chainspec.rs +++ b/crates/node/src/chainspec.rs @@ -1,6 +1,7 @@ use crate::utils::download_and_cache_genesis_verified; -use alloy_primitives::b256; +use alloy_primitives::{b256, hex}; use fluentbase_genesis::local_genesis_from_file; +use fluentbase_release_verify::ReleaseAsset; use reth_chainspec::{ make_genesis_header, BaseFeeParams, BaseFeeParamsKind, Chain, ChainHardforks, ChainSpec, EthereumHardfork, ForkCondition, Hardfork, DEV_HARDFORKS, @@ -11,14 +12,52 @@ use reth_revm::primitives::U256; use std::sync::{Arc, LazyLock}; use tracing::warn; -/// Release tag for Fluent Mainnet genesis -const FLUENT_MAINNET_GENESIS_TAG: &str = "v1.0.0"; +// Genesis assets for the built-in networks. +// +// Each entry names a GitHub release asset and pins the SHA-256 of the exact `.json.gz` published +// there. The pins are checked in addition to the detached OpenPGP signature; when a tag is bumped, +// re-pin with `shasum -a 256 ` after confirming the asset's `.asc` verifies against the +// release key pinned in `fluentbase-release-verify`. -/// Release tag for Fluent Testnet genesis (GitHub releases). -const FLUENT_TESTNET_GENESIS_TAG: &str = "v0.3.4-dev"; +/// Genesis asset for Fluent Devnet (GitHub releases). +fn devnet_genesis() -> ReleaseAsset { + ReleaseAsset::genesis("v0.5.7", None) + .expect("built-in devnet release asset must be valid") + .with_sha256(hex!( + "91b9a427805d45dd14e46a0cd517bcc85f350fe7dfc38fa96f6ff0ebf5e864da" + )) +} + +/// Genesis asset for Fluent Testnet (GitHub releases). +fn testnet_genesis() -> ReleaseAsset { + ReleaseAsset::genesis("v0.3.4-dev", None) + .expect("built-in testnet release asset must be valid") + .with_sha256(hex!( + "8cd30358c5664375e6739bc48302445e7ee10fd0158bedb788505e5c590983bd" + )) +} + +/// Genesis asset for Fluent Mainnet (GitHub releases). +fn mainnet_genesis() -> ReleaseAsset { + ReleaseAsset::genesis("v1.0.0", Some("mainnet")) + .expect("built-in mainnet release asset must be valid") + .with_sha256(hex!( + "72cb4b3b7b15de952bd1094281a1f2430cb711bc473a0520f92aa3e2b1bdb643" + )) +} -/// Release tag for Fluent Devnet genesis (GitHub releases). -const FLUENT_DEVNET_GENESIS_TAG: &str = "v0.5.7"; +/// Every genesis asset a built-in network can be started from. +/// +/// Kept as a table so tests can assert the fail-closed guarantees hold for all of them; keep it in +/// sync when a network is added. +#[cfg(test)] +pub(crate) fn built_in_genesis_assets() -> Vec<(&'static str, ReleaseAsset)> { + vec![ + ("fluent-devnet", devnet_genesis()), + ("fluent-testnet", testnet_genesis()), + ("fluent-mainnet", mainnet_genesis()), + ] +} pub const FLUENT_LOCALNET_CHAIN_ID: u64 = 1337; pub const FLUENT_DEVNET_CHAIN_ID: u64 = 0x5201; @@ -44,7 +83,7 @@ pub static FLUENT_LOCAL: LazyLock> = LazyLock::new(|| { /// Fluent Devnet pub static FLUENT_DEVNET: LazyLock> = LazyLock::new(|| { - let genesis = download_and_cache_genesis_verified(FLUENT_DEVNET_GENESIS_TAG, None) + let genesis = download_and_cache_genesis_verified(&devnet_genesis()) .expect("failed to download/verify Fluent devnet genesis"); let hardforks = fluent_default_chain_hardforks(ForkCondition::Block(0)); ChainSpec { @@ -62,7 +101,7 @@ pub static FLUENT_DEVNET: LazyLock> = LazyLock::new(|| { /// Fluent Testnet pub static FLUENT_TESTNET: LazyLock> = LazyLock::new(|| { - let genesis = download_and_cache_genesis_verified(FLUENT_TESTNET_GENESIS_TAG, None) + let genesis = download_and_cache_genesis_verified(&testnet_genesis()) .expect("failed to download/verify Fluent testnet genesis"); let hardforks = fluent_default_chain_hardforks(ForkCondition::Block(21_300_000)); ChainSpec { @@ -80,7 +119,7 @@ pub static FLUENT_TESTNET: LazyLock> = LazyLock::new(|| { /// Fluent Mainnet pub static FLUENT_MAINNET: LazyLock> = LazyLock::new(|| { - let genesis = download_and_cache_genesis_verified(FLUENT_MAINNET_GENESIS_TAG, Some("mainnet")) + let genesis = download_and_cache_genesis_verified(&mainnet_genesis()) .expect("failed to download/verify Fluent mainnet genesis"); let hardforks = fluent_default_chain_hardforks(ForkCondition::Timestamp(0)); let genesis_header = SealedHeader::new_unhashed(make_genesis_header(&genesis, &hardforks)); @@ -105,6 +144,14 @@ pub static FLUENT_MAINNET: LazyLock> = LazyLock::new(|| { .into() }); +/// Fork schedule for the protocol and the native REVM side (precompile sets, transaction rules, +/// and so on). +/// +/// It does not govern EVM bytecode semantics. Contracts delegating to `PRECOMPILE_EVM_RUNTIME` +/// execute at the hardfork pinned by the deployed EVM runtime contract, which is upgraded +/// forklessly rather than activated here — so a `osaka_fork` condition scheduled in the future +/// does not hold back Osaka opcodes inside delegated bytecode. That is intended; see the +/// `fluentbase_evm::evm` module docs. fn fluent_default_chain_hardforks(osaka_fork: ForkCondition) -> ChainHardforks { ChainHardforks::new(vec![ (EthereumHardfork::Frontier.boxed(), ForkCondition::Block(0)), diff --git a/crates/node/src/utils.rs b/crates/node/src/utils.rs index 28773f4c7..00e562ccb 100644 --- a/crates/node/src/utils.rs +++ b/crates/node/src/utils.rs @@ -1,167 +1,108 @@ +//! Genesis retrieval for the built-in networks. +//! +//! Built-in networks (devnet / testnet / mainnet) take their genesis state from a `.json.gz` asset +//! published on GitHub releases. Everything about trusting that asset — the pinned release key, +//! detached signature verification, digest pinning, and the fail-closed cache handling — lives in +//! [`fluentbase_release_verify`]. This module only supplies the HTTP client and the cache location. + use alloy_genesis::Genesis; -use std::{ - fs, - io::{Read, Write}, - path::{Path, PathBuf}, +use eyre::WrapErr as _; +use fluentbase_release_verify::{ + bounded_http_fetch, load_verified, parse_genesis_gz, FetchError, ReleaseAsset, ReleaseKey, }; +use std::{path::PathBuf, time::Duration}; -const MAX_GENESIS_JSON_BYTES: u64 = 256 * 1024 * 1024; +/// Timeout for a single artifact download. +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300); -/// Downloads genesis from GitHub releases into `../genesis/` (sibling to this crate), -/// caches it, and verifies its detached OpenPGP signature. +/// Loads the genesis of a built-in network, authenticating the artifact before use. /// /// This is intentionally synchronous because it runs during CLI startup / chainspec selection. -pub fn download_and_cache_genesis_verified( - tag: &str, - channel: Option<&str>, -) -> eyre::Result { - use eyre::WrapErr as _; - - let (gz_url, asc_url, gz_name, asc_name) = genesis_urls(tag, channel); - - println!("Checking genesis for tag {}...", tag); - let genesis_dir = genesis_cache_dir()?; - fs::create_dir_all(&genesis_dir).wrap_err_with(|| { - format!( - "failed to create genesis cache dir {}", - genesis_dir.display() - ) - })?; - - let gz_path = genesis_dir.join(&gz_name); - let asc_path = genesis_dir.join(&asc_name); - - // Fast path: already cached and signature verifies. - if gz_path.exists() && asc_path.exists() { - if verify_detached_signature(&gz_path, &asc_path).is_ok() { - println!("Using cached genesis from {}", gz_path.display()); - return read_genesis_from_gz(&gz_path); - } - // Corrupted / wrong key / tampered -> redownload. - let _ = fs::remove_file(&gz_path); - let _ = fs::remove_file(&asc_path); - } - - println!("Genesis not found in cache, downloading from {}", gz_url); - - // Download both files. - download_to(&gz_url, &gz_path).wrap_err("failed to download genesis .gz")?; - download_to(&asc_url, &asc_path).wrap_err("failed to download genesis .asc")?; - - println!("Verifying signature genesis signature..."); - - // Verify, then read. - verify_detached_signature(&gz_path, &asc_path) - .wrap_err("genesis signature verification failed")?; - read_genesis_from_gz(&gz_path) +pub fn download_and_cache_genesis_verified(asset: &ReleaseAsset) -> eyre::Result { + let cache_dir = genesis_cache_dir()?; + let key = ReleaseKey::fluent().wrap_err("release key is unusable")?; + + println!("Checking genesis for release {}...", asset.tag()); + let artifact = + load_verified(Some(&cache_dir), asset, &key, &http_fetch).wrap_err_with(|| { + format!( + "genesis authentication failed for {} — refusing to start", + asset.name() + ) + })?; + println!( + "Using genesis {} (sha256 {})", + artifact.name, + artifact.sha256_hex() + ); + + parse_genesis_gz(&artifact).map_err(Into::into) } -/// Where to cache genesis files: `../genesis` relative to this crate's `Cargo.toml`. +/// Where to cache genesis files. fn genesis_cache_dir() -> eyre::Result { let proj = directories::ProjectDirs::from("xyz", "fluentlabs", "fluent") .ok_or_else(|| eyre::eyre!("cannot determine cache directory"))?; Ok(proj.cache_dir().join("genesis")) } -/// Build release URLs & filenames for the given tag. -pub fn genesis_urls(tag: &str, channel: Option<&str>) -> (String, String, String, String) { - let base = format!("https://github.com/fluentlabs-xyz/fluentbase/releases/download/{tag}"); - let gz_name = if let Some(channel) = channel { - format!("genesis-{channel}-{tag}.json.gz") - } else { - format!("genesis-{tag}.json.gz") - }; - let asc_name = format!("{gz_name}.asc"); - let gz_url = format!("{base}/{gz_name}"); - let asc_url = format!("{base}/{asc_name}"); - (gz_url, asc_url, gz_name, asc_name) -} - -/// Download `url` to `path` atomically (write to temp, then rename). -pub fn download_to(url: &str, path: &Path) -> eyre::Result<()> { - use eyre::WrapErr as _; - let tmp = path.with_extension("tmp"); - +/// Downloads `url` into memory, refusing responses larger than `max_bytes`. +fn http_fetch(url: &str, max_bytes: usize) -> Result, FetchError> { // NOTE: blocking client avoids pulling tokio into a CLI dependency tree. - let resp = reqwest::blocking::Client::builder() - .user_agent("fluent-chainspec/1.0") - .timeout(std::time::Duration::from_secs(60)) - .build() - .wrap_err("failed to build HTTP client")? - .get(url) - .send() - .wrap_err_with(|| format!("GET {url}"))? - .error_for_status() - .wrap_err_with(|| format!("GET {url} returned non-success"))?; - - let bytes = resp - .bytes() - .wrap_err_with(|| format!("reading body from {url}"))?; - - { - let mut f = fs::File::create(&tmp) - .wrap_err_with(|| format!("failed to create {}", tmp.display()))?; - f.write_all(&bytes) - .wrap_err_with(|| format!("failed to write {}", tmp.display()))?; - f.sync_all() - .wrap_err_with(|| format!("failed to sync {}", tmp.display()))?; - } - - fs::rename(&tmp, path) - .wrap_err_with(|| format!("failed to move {} -> {}", tmp.display(), path.display()))?; - Ok(()) + bounded_http_fetch("fluent-chainspec/1.0", DOWNLOAD_TIMEOUT, url, max_bytes) } -/// Read a gzipped genesis JSON into [`Genesis`]. -fn read_genesis_from_gz(path: &Path) -> eyre::Result { - use eyre::WrapErr as _; - let gz = fs::read(path).wrap_err_with(|| format!("failed to read {}", path.display()))?; - let decoder = flate2::read::GzDecoder::new(&gz[..]); - let mut json = Vec::new(); - decoder - .take(MAX_GENESIS_JSON_BYTES + 1) - .read_to_end(&mut json) - .wrap_err("failed to decompress genesis gz")?; - if json.len() as u64 > MAX_GENESIS_JSON_BYTES { - eyre::bail!( - "genesis JSON exceeds {} byte decompressed limit", - MAX_GENESIS_JSON_BYTES - ); +#[cfg(test)] +mod tests { + use super::*; + use crate::chainspec::built_in_genesis_assets; + + #[test] + fn built_in_networks_have_pinned_digests_and_expected_asset_names() { + for (name, asset) in built_in_genesis_assets() { + assert!( + asset.sha256().is_some(), + "{name} genesis asset has no digest pin" + ); + assert!( + asset.name().starts_with("genesis-") && asset.name().ends_with(".json.gz"), + "{name}: unexpected asset name {}", + asset.name() + ); + assert_eq!( + asset.signature_name(), + format!("{}.asc", asset.name()), + "{name}: signature name must follow the asset name" + ); + for url in [asset.url(), asset.signature_url()] { + assert!( + url.starts_with( + "https://github.com/fluentlabs-xyz/fluentbase/releases/download/" + ), + "{name}: unexpected download URL {url}" + ); + } + } } - let genesis = - serde_json::from_slice::(&json).wrap_err("failed to parse genesis JSON")?; - Ok(genesis) -} - -/// ASCII-armored OpenPGP public key used to verify genesis artifacts. -#[allow(dead_code)] -const FLUENT_RELEASE_PUBKEY_ASC: &str = "-----BEGIN PGP PUBLIC KEY BLOCK----- -mDMEaEq6ORYJKwYBBAHaRw8BAQdADSciIyJRuaPogw2vJ388jlOsKRQk1c84vUpn -NT+vmeu0J0RtaXRyaWkgU2F2b25pbiA8ZG1pdHJ5QGZsdWVudGxhYnMueHl6PoiT -BBMWCgA7FiEECm0F5d2YBpuhhO2DBKaNYg1SCP0FAmhKujkCGwMFCwkIBwICIgIG -FQoJCAsCBBYCAwECHgcCF4AACgkQBKaNYg1SCP0eRwEA43IlexWb2Nh/rVzVyRVg -fPLZ45a13AP0iMCnAhjFK/cBAL5zDzWNNFkxHm6XGYQC4mHWLeZFe3gIJVQ0Y+wH -hCoHuDgEaEq6ORIKKwYBBAGXVQEFAQEHQCBTP3PIjJhuMZdF5aVuEiPODt9EpEnK -Jph+AW0cmfZ2AwEIB4h4BBgWCgAgFiEECm0F5d2YBpuhhO2DBKaNYg1SCP0FAmhK -ujkCGwwACgkQBKaNYg1SCP2KwgD/UJk7eQhlLNosZNLOyFj48241KcG2lJbCgzt8 -XehpkCgA/13esUBYao//zRco9fgrVbSBNJ7FO1G0jXAYygDqCYsJ -=Ortc ------END PGP PUBLIC KEY BLOCK-----"; -/// Verify the detached OpenPGP signature (`.asc`) for the given file. -/// -/// This expects an ASCII-armored public key either from env var -/// `FLUENT_RELEASE_PUBKEY_ASC` or from `FLUENT_RELEASE_PUBKEY_ASC_FALLBACK`. -fn verify_detached_signature(_data_path: &Path, _sig_path: &Path) -> eyre::Result<()> { - // use sequoia_openpgp::{Cert}; - // if std::env::var("SKIP_SIGNATURE_VERIFICATION").is_ok() { - // return Ok(()); - // } - // let cert = Cert::from_reader(FLUENT_RELEASE_PUBKEY_ASC.as_bytes()).unwrap(); - // let sig_bytes = fs::read(sig_path) - // .wrap_err_with(|| format!("failed to read signature {}", sig_path.display()))?; - // let sqv = sequoia_sqv::Sqv::new(); - // sqv.verify_detached(&cert, sig, file)?; - //TODO(dmitry123): Make it work - Ok(()) + /// Every built-in network must refuse a cache that was swapped for a differently signed + /// artifact, whether or not the release is reachable. The verification itself is covered by + /// `fluentbase-release-verify`; this pins the guarantee to the concrete network table. + #[test] + fn every_built_in_network_rejects_a_substituted_cache() { + use fluentbase_release_verify::authenticate; + + let key = ReleaseKey::fluent().expect("embedded key must load"); + let evil = b"substituted genesis".to_vec(); + let no_signature = b"-----BEGIN PGP SIGNATURE-----\n\nzz\n-----END PGP SIGNATURE-----\n"; + + for (name, asset) in built_in_genesis_assets() { + let err = authenticate(&evil, no_signature, &asset, &key) + .expect_err("substituted content must not authenticate"); + assert!( + err.to_string().contains("digest mismatch"), + "{name}: unexpected error: {err}" + ); + } + } } diff --git a/crates/release-verify/Cargo.toml b/crates/release-verify/Cargo.toml new file mode 100644 index 000000000..1f2e0950c --- /dev/null +++ b/crates/release-verify/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "fluentbase-release-verify" +version.workspace = true +description = "Fail-closed authentication of Fluent release artifacts against the pinned release key" +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +readme.workspace = true + +[dependencies] +alloy-genesis = { workspace = true } +flate2 = { workspace = true } +hex = { workspace = true, features = ["std"] } +pgp = { workspace = true } +reqwest = { workspace = true, optional = true } +serde_json = { workspace = true, features = ["std"] } +sha2 = { workspace = true, features = ["std"] } +thiserror = { workspace = true } +tracing = { workspace = true } +# Only pulled in by `test-support`; rPGP is built against rand 0.8, so signing fixtures need it. +rand_08 = { workspace = true, optional = true } + +[features] +# Shared bounded blocking HTTP transport for release-asset consumers. +reqwest = ["dep:reqwest"] +# Fixtures for building throwaway releases in downstream tests. Dev-dependencies only. +test-support = ["dep:rand_08"] + +[dev-dependencies] +rand_08 = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/release-verify/src/asset.rs b/crates/release-verify/src/asset.rs new file mode 100644 index 000000000..64138a469 --- /dev/null +++ b/crates/release-verify/src/asset.rs @@ -0,0 +1,126 @@ +use crate::{Result, VerifyError}; +use std::borrow::Cow; + +/// Where Fluent release assets are published. +pub const RELEASE_BASE_URL: &str = "https://github.com/fluentlabs-xyz/fluentbase/releases/download"; + +/// Byte budget for a compressed release artifact. +pub const MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024; + +/// Byte budget for a detached signature. +pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024; + +/// Byte budget for a release manifest. +pub const MAX_MANIFEST_BYTES: usize = 1024 * 1024; + +/// Byte budget for decompressed genesis JSON. +pub const MAX_GENESIS_JSON_BYTES: u64 = 256 * 1024 * 1024; + +/// A release asset to authenticate, identified by the release it belongs to and its file name. +/// +/// The name is part of the identity on purpose: a signature that verifies over some other asset of +/// the same release must not be usable here, which is what the digest pin and the manifest lookup +/// (both keyed by name) enforce. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseAsset { + tag: Cow<'static, str>, + name: Cow<'static, str>, + sha256: Option<[u8; 32]>, + max_bytes: usize, +} + +impl ReleaseAsset { + /// An arbitrary asset of release `tag`. + pub fn new( + tag: impl Into>, + name: impl Into>, + ) -> Result { + let tag = tag.into(); + let name = name.into(); + validate_component("release tag", &tag)?; + validate_component("asset name", &name)?; + Ok(Self { + tag, + name, + sha256: None, + max_bytes: MAX_ARTIFACT_BYTES, + }) + } + + /// The compressed genesis asset of release `tag`, for the given channel (`None` = devnet). + pub fn genesis(tag: impl Into>, channel: Option<&str>) -> Result { + let tag = tag.into(); + let name = match channel { + Some(channel) => { + validate_component("release channel", channel)?; + format!("genesis-{channel}-{tag}.json.gz") + } + None => format!("genesis-{tag}.json.gz"), + }; + Self::new(tag, name) + } + + /// The signed digest manifest of release `tag`. + pub fn manifest(tag: impl Into>) -> Result { + let tag = tag.into(); + let name = format!("genesis-manifest-{tag}.txt"); + Ok(Self::new(tag, name)?.with_max_bytes(MAX_MANIFEST_BYTES)) + } + + /// Pins the exact SHA-256 this asset must have. + pub fn with_sha256(mut self, sha256: [u8; 32]) -> Self { + self.sha256 = Some(sha256); + self + } + + /// Overrides the byte budget for this asset. + pub fn with_max_bytes(mut self, max_bytes: usize) -> Self { + self.max_bytes = max_bytes; + self + } + + pub fn tag(&self) -> &str { + &self.tag + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn sha256(&self) -> Option<[u8; 32]> { + self.sha256 + } + + pub fn max_bytes(&self) -> usize { + self.max_bytes + } + + /// Name of the detached signature that accompanies this asset. + pub fn signature_name(&self) -> String { + format!("{}.asc", self.name) + } + + pub fn url(&self) -> String { + format!("{RELEASE_BASE_URL}/{}/{}", self.tag, self.name) + } + + pub fn signature_url(&self) -> String { + format!("{}.asc", self.url()) + } +} + +/// GitHub release tags and asset names are single URL/path components. Keeping a narrow character +/// set prevents cache traversal and URL path/query injection before either string reaches a sink. +fn validate_component(kind: &str, value: &str) -> Result<()> { + if value.is_empty() + || matches!(value, "." | "..") + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-')) + { + return Err(VerifyError::Asset(format!( + "{kind} {value:?} must be a non-empty ASCII path component" + ))); + } + Ok(()) +} diff --git a/crates/release-verify/src/error.rs b/crates/release-verify/src/error.rs new file mode 100644 index 000000000..742ef124b --- /dev/null +++ b/crates/release-verify/src/error.rs @@ -0,0 +1,111 @@ +use std::fmt::Display; + +/// Everything that can stop an artifact from being trusted. +/// +/// Every variant is a refusal: there is no "warning" outcome and no bypass. Callers turn this into +/// their own error type (`anyhow`, `eyre`, …) at the boundary. +#[derive(Debug, thiserror::Error)] +pub enum VerifyError { + #[error("failed to parse the release public key: {0}")] + KeyParse(String), + + #[error("release public key has invalid self-signatures: {0}")] + KeyBindings(String), + + #[error("release public key fingerprint mismatch: expected {expected}, got {actual}")] + KeyFingerprint { expected: String, actual: String }, + + #[error("release public key violates verifier policy: {0}")] + KeyPolicy(String), + + #[error("invalid release asset: {0}")] + Asset(String), + + #[error("failed to parse detached signature: {0}")] + SignatureParse(String), + + #[error("unexpected signature type {0}, expected a binary signature")] + SignatureType(String), + + #[error("signature uses rejected digest algorithm {0}")] + WeakDigest(String), + + #[error("signature does not identify an issuer")] + MissingIssuer, + + #[error("signature was not issued by the pinned release key")] + UntrustedIssuer, + + #[error("signature does not verify against the release key: {0}")] + BadSignature(String), + + #[error("digest mismatch for {name}: expected sha256 {expected}, got {actual}")] + DigestMismatch { + name: String, + expected: String, + actual: String, + }, + + #[error("{what} exceeds the {limit} byte limit")] + TooLarge { what: String, limit: usize }, + + #[error("release manifest: {0}")] + Manifest(String), + + #[error("{path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("failed to fetch {url}: {source}")] + Fetch { + url: String, + #[source] + source: FetchError, + }, + + #[error("failed to decompress {0}")] + Decompress(String), + + #[error("failed to parse genesis JSON: {0}")] + GenesisParse(String), +} + +/// Transport-level failure reported by a [`crate::Fetcher`]. +/// +/// Deliberately a flat string: it exists so callers can plug in any HTTP client without this crate +/// having to agree with them on an error type. +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct FetchError { + message: String, + not_found: bool, +} + +impl FetchError { + pub fn new(error: impl Display) -> Self { + Self { + message: error.to_string(), + not_found: false, + } + } + + /// Reports that the requested release asset does not exist. + /// + /// Callers may distinguish this from transient transport failures when an optional asset is + /// genuinely absent from older releases. + pub fn not_found(error: impl Display) -> Self { + Self { + message: error.to_string(), + not_found: true, + } + } + + pub fn is_not_found(&self) -> bool { + self.not_found + } +} + +pub type Result = std::result::Result; diff --git a/crates/release-verify/src/http.rs b/crates/release-verify/src/http.rs new file mode 100644 index 000000000..438ef28ac --- /dev/null +++ b/crates/release-verify/src/http.rs @@ -0,0 +1,55 @@ +use crate::FetchError; +use std::{io::Read as _, time::Duration}; + +/// Downloads `url` with a blocking client, refusing responses larger than `max_bytes`. +/// +/// `404 Not Found` and `410 Gone` are reported through [`FetchError::is_not_found`], allowing a +/// caller to distinguish an absent optional release asset from a transient transport failure. +pub fn bounded_http_fetch( + user_agent: &str, + timeout: Duration, + url: &str, + max_bytes: usize, +) -> Result, FetchError> { + let client = reqwest::blocking::Client::builder() + .user_agent(user_agent) + .timeout(timeout) + .build() + .map_err(|err| FetchError::new(format!("failed to build HTTP client: {err}")))?; + let response = client + .get(url) + .send() + .map_err(|err| FetchError::new(format!("GET {url}: {err}")))?; + if matches!( + response.status(), + reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::GONE + ) { + return Err(FetchError::not_found(format!( + "GET {url} returned {}", + response.status() + ))); + } + let response = response + .error_for_status() + .map_err(|err| FetchError::new(format!("GET {url} returned non-success: {err}")))?; + + if let Some(len) = response.content_length() { + if len > max_bytes as u64 { + return Err(FetchError::new(format!( + "advertises {len} bytes, over the {max_bytes} byte limit" + ))); + } + } + + let mut bytes = Vec::new(); + response + .take(max_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|err| FetchError::new(format!("reading response body: {err}")))?; + if bytes.len() > max_bytes { + return Err(FetchError::new(format!( + "exceeds the {max_bytes} byte limit" + ))); + } + Ok(bytes) +} diff --git a/crates/release-verify/src/key.rs b/crates/release-verify/src/key.rs new file mode 100644 index 000000000..d5720771e --- /dev/null +++ b/crates/release-verify/src/key.rs @@ -0,0 +1,194 @@ +use crate::error::{Result, VerifyError}; +use pgp::{ + composed::{Deserializable as _, SignedPublicKey, SignedPublicSubKey}, + packet::{Signature, SignatureType}, + types::{Fingerprint, KeyDetails as _}, +}; + +/// ASCII-armored OpenPGP public key used to authenticate Fluent release artifacts. +pub const FLUENT_RELEASE_PUBKEY_ASC: &str = "-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEaEq6ORYJKwYBBAHaRw8BAQdADSciIyJRuaPogw2vJ388jlOsKRQk1c84vUpn +NT+vmeu0J0RtaXRyaWkgU2F2b25pbiA8ZG1pdHJ5QGZsdWVudGxhYnMueHl6PoiT +BBMWCgA7FiEECm0F5d2YBpuhhO2DBKaNYg1SCP0FAmhKujkCGwMFCwkIBwICIgIG +FQoJCAsCBBYCAwECHgcCF4AACgkQBKaNYg1SCP0eRwEA43IlexWb2Nh/rVzVyRVg +fPLZ45a13AP0iMCnAhjFK/cBAL5zDzWNNFkxHm6XGYQC4mHWLeZFe3gIJVQ0Y+wH +hCoHuDgEaEq6ORIKKwYBBAGXVQEFAQEHQCBTP3PIjJhuMZdF5aVuEiPODt9EpEnK +Jph+AW0cmfZ2AwEIB4h4BBgWCgAgFiEECm0F5d2YBpuhhO2DBKaNYg1SCP0FAmhK +ujkCGwwACgkQBKaNYg1SCP2KwgD/UJk7eQhlLNosZNLOyFj48241KcG2lJbCgzt8 +XehpkCgA/13esUBYao//zRco9fgrVbSBNJ7FO1G0jXAYygDqCYsJ +=Ortc +-----END PGP PUBLIC KEY BLOCK-----"; + +/// Fingerprint the embedded release certificate must have. +/// +/// Pinning the fingerprint separately from the armored blob means a subtle edit to the key material +/// above cannot go unnoticed: [`ReleaseKey::new`] refuses to build a verifier from a certificate +/// whose fingerprint does not match. +pub const FLUENT_RELEASE_KEY_FINGERPRINT: &str = "0A6D05E5DD98069BA184ED8304A68D620D5208FD"; + +/// A release certificate that has been parsed, self-checked, and matched against its pin. +/// +/// Holding one is the proof that the trust root is the intended one; every verification entry point +/// takes a `ReleaseKey` rather than raw armor so that check cannot be skipped. +/// +/// The embedded certificate is an offline trust snapshot: this verifier does not query a keyserver. +/// Revocation therefore requires publishing an updated binary containing the revocation or a newly +/// pinned key. When the embedded snapshot contains revocation or expiration metadata, verification +/// enforces it against the current time and refuses revoked or expired signing components. +#[derive(Debug, Clone)] +pub struct ReleaseKey { + cert: SignedPublicKey, +} + +impl ReleaseKey { + /// The key Fluent releases are signed with. + pub fn fluent() -> Result { + Self::new(FLUENT_RELEASE_PUBKEY_ASC, FLUENT_RELEASE_KEY_FINGERPRINT) + } + + /// Parses an armored certificate and checks it against `expected_fingerprint`. + pub fn new(pubkey_asc: &str, expected_fingerprint: &str) -> Result { + let (cert, _headers) = SignedPublicKey::from_string(pubkey_asc) + .map_err(|err| VerifyError::KeyParse(err.to_string()))?; + + cert.verify_bindings() + .map_err(|err| VerifyError::KeyBindings(err.to_string()))?; + validate_certificate_policy_at(&cert, pgp::types::Timestamp::now().as_secs() as u64)?; + + let actual = fingerprint_hex(&cert.fingerprint()); + if actual != expected_fingerprint { + return Err(VerifyError::KeyFingerprint { + expected: expected_fingerprint.to_owned(), + actual, + }); + } + + Ok(Self { cert }) + } + + /// Uppercase hex fingerprint of the primary key. + pub fn fingerprint(&self) -> String { + fingerprint_hex(&self.cert.fingerprint()) + } + + pub(crate) fn cert(&self) -> &SignedPublicKey { + &self.cert + } +} + +pub(crate) fn validate_certificate_policy_at(cert: &SignedPublicKey, now: u64) -> Result<()> { + if !cert.details.revocation_signatures.is_empty() { + return Err(VerifyError::KeyPolicy( + "the primary release key is revoked".to_owned(), + )); + } + if !primary_can_sign_at(cert, now) + && !cert + .public_subkeys + .iter() + .any(|subkey| subkey_can_sign_at(subkey, now)) + { + return Err(VerifyError::KeyPolicy( + "the certificate has no unrevoked, unexpired signing component".to_owned(), + )); + } + Ok(()) +} + +pub(crate) fn primary_can_sign_at(cert: &SignedPublicKey, now: u64) -> bool { + cert.details + .direct_signatures + .iter() + .chain( + cert.details + .users + .iter() + .flat_map(|user| user.signatures.iter()), + ) + .chain( + cert.details + .user_attributes + .iter() + .flat_map(|attribute| attribute.signatures.iter()), + ) + .any(|signature| { + signature.key_flags().sign() + && component_signature_is_current( + signature, + cert.primary_key.created_at().as_secs() as u64, + now, + ) + }) +} + +pub(crate) fn subkey_can_sign_at(subkey: &SignedPublicSubKey, now: u64) -> bool { + if subkey + .signatures + .iter() + .any(|signature| signature.typ() == Some(SignatureType::SubkeyRevocation)) + { + return false; + } + subkey.signatures.iter().any(|signature| { + signature.typ() == Some(SignatureType::SubkeyBinding) + && signature.key_flags().sign() + && component_signature_is_current( + signature, + subkey.key.created_at().as_secs() as u64, + now, + ) + }) +} + +fn component_signature_is_current(signature: &Signature, key_created: u64, now: u64) -> bool { + let Some(signature_created) = signature.created().map(|created| created.as_secs() as u64) + else { + return false; + }; + time_window_is_current( + signature_created, + signature + .signature_expiration_time() + .map(|duration| duration.as_secs() as u64), + key_created, + signature + .key_expiration_time() + .map(|duration| duration.as_secs() as u64), + now, + ) +} + +fn time_window_is_current( + signature_created: u64, + signature_lifetime: Option, + key_created: u64, + key_lifetime: Option, + now: u64, +) -> bool { + if signature_created > now { + return false; + } + let expired = |created: u64, lifetime: Option| { + lifetime.is_some_and(|lifetime| lifetime != 0 && created.saturating_add(lifetime) <= now) + }; + !expired(signature_created, signature_lifetime) && !expired(key_created, key_lifetime) +} + +pub(crate) fn fingerprint_hex(fingerprint: &Fingerprint) -> String { + hex::encode_upper(fingerprint.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::time_window_is_current; + + #[test] + fn component_time_window_rejects_future_and_expired_components() { + assert!(!time_window_is_current(101, None, 90, None, 100)); + assert!(!time_window_is_current(90, Some(10), 80, None, 100)); + assert!(!time_window_is_current(90, None, 80, Some(20), 100)); + assert!(time_window_is_current(90, Some(0), 80, Some(0), 100)); + assert!(time_window_is_current(90, Some(11), 80, Some(21), 100)); + } +} diff --git a/crates/release-verify/src/lib.rs b/crates/release-verify/src/lib.rs new file mode 100644 index 000000000..23ba628bc --- /dev/null +++ b/crates/release-verify/src/lib.rs @@ -0,0 +1,67 @@ +//! Fail-closed authentication of Fluent release artifacts. +//! +//! Release assets (`genesis-*.json.gz`, the digest manifest, …) are published on GitHub together +//! with detached OpenPGP signatures. Anything derived from them — genesis allocations, system +//! contract code, runtime-upgrade payloads — is only as trustworthy as that signature check, so +//! this crate treats every artifact as untrusted input until it has been authenticated against the +//! release key pinned in [`key`]. +//! +//! The rules are deliberately fail-closed: +//! +//! * artifacts are held in memory until authentication succeeds — nothing is decompressed, parsed, +//! or written to a cache before that; +//! * the signature is checked over the exact bytes that are later used, so there is no window +//! between verification and use; +//! * cached files get the same treatment as freshly downloaded ones, and are discarded rather than +//! trusted when they fail; +//! * digest pins and the signed [`manifest`] are checked in addition to the signature, never +//! instead of it; +//! * every read, parse, signer, or signature error is an error. There is no bypass switch. +//! +//! [`load_verified`] takes a [`Fetcher`] closure. Consumers that use blocking HTTP can enable the +//! `reqwest` feature and reuse [`bounded_http_fetch`]; other transports remain injectable. +//! +//! ```no_run +//! use fluentbase_release_verify::{load_verified, parse_genesis_gz, ReleaseAsset, ReleaseKey}; +//! +//! # fn main() -> Result<(), Box> { +//! let key = ReleaseKey::fluent()?; +//! let asset = ReleaseAsset::genesis("v1.3.2", None)?; +//! let artifact = load_verified(Some(std::path::Path::new("/tmp/cache")), &asset, &key, &|_, _| { +//! unimplemented!("plug in an HTTP client") +//! })?; +//! let genesis = parse_genesis_gz(&artifact)?; +//! # let _ = genesis; +//! # Ok(()) +//! # } +//! ``` + +pub mod asset; +pub mod error; +#[cfg(feature = "reqwest")] +mod http; +pub mod key; +pub mod load; +pub mod manifest; +pub mod signature; + +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + +pub use asset::{ + ReleaseAsset, MAX_ARTIFACT_BYTES, MAX_GENESIS_JSON_BYTES, MAX_MANIFEST_BYTES, + MAX_SIGNATURE_BYTES, RELEASE_BASE_URL, +}; +pub use error::{FetchError, Result, VerifyError}; +#[cfg(feature = "reqwest")] +pub use http::bounded_http_fetch; +pub use key::{ReleaseKey, FLUENT_RELEASE_KEY_FINGERPRINT, FLUENT_RELEASE_PUBKEY_ASC}; +pub use load::{ + authenticate, decompress_gz, load_verified, parse_genesis_gz, read_capped, write_atomic, + Fetcher, VerifiedArtifact, +}; +pub use manifest::ReleaseManifest; +pub use signature::verify_detached_signature; + +#[cfg(test)] +mod tests; diff --git a/crates/release-verify/src/load.rs b/crates/release-verify/src/load.rs new file mode 100644 index 000000000..cdbdf6eb1 --- /dev/null +++ b/crates/release-verify/src/load.rs @@ -0,0 +1,239 @@ +use crate::{ + asset::{ReleaseAsset, MAX_GENESIS_JSON_BYTES, MAX_SIGNATURE_BYTES}, + error::{FetchError, Result, VerifyError}, + key::ReleaseKey, + signature::verify_detached_signature, +}; +use alloy_genesis::Genesis; +use sha2::{Digest as _, Sha256}; +use std::{ + ffi::OsString, + fs::{self, OpenOptions}, + io::{Read as _, Write as _}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; +use tracing::warn; + +/// Fetches `url` into memory, refusing anything larger than the given byte budget. +/// +/// Callers supply their own HTTP client; this crate never opens a socket. +pub type Fetcher<'a> = dyn Fn(&str, usize) -> std::result::Result, FetchError> + 'a; + +/// An artifact that passed authentication, together with the digest it was authenticated at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedArtifact { + /// File name of the asset, as published. + pub name: String, + /// The exact bytes that were verified. Anything derived from the artifact must come from here. + pub bytes: Vec, + /// SHA-256 of `bytes`. + pub sha256: [u8; 32], +} + +impl VerifiedArtifact { + /// Hex digest, for logging and for manifest cross-checks. + pub fn sha256_hex(&self) -> String { + hex::encode(self.sha256) + } +} + +/// Loads a release asset from cache or from the release, authenticating it before it is usable. +/// +/// The order of operations is the security property: bytes are held in memory, checked against the +/// digest pin (when there is one) and the detached signature, and only then returned or written to +/// the cache. A cached pair that fails authentication is deleted and re-fetched; it is never used. +/// If the asset cannot be authenticated, this returns an error — there is no degraded mode. +pub fn load_verified( + cache_dir: Option<&Path>, + asset: &ReleaseAsset, + key: &ReleaseKey, + fetch: &Fetcher<'_>, +) -> Result { + let cached_paths = cache_dir.map(|dir| { + ( + dir.join(asset.name()), + dir.join(asset.signature_name()), + dir, + ) + }); + + if let Some((artifact_path, signature_path, _)) = &cached_paths { + if let Some((bytes, asc)) = read_cached_pair(artifact_path, signature_path, asset) { + match authenticate(&bytes, &asc, asset, key) { + Ok(artifact) => return Ok(artifact), + Err(err) => { + // Never fall back to the cached bytes: drop them and re-fetch. + warn!( + "cached {} failed authentication ({err}); discarding and re-downloading", + artifact_path.display() + ); + let _ = fs::remove_file(artifact_path); + let _ = fs::remove_file(signature_path); + } + } + } + } + + let url = asset.url(); + let bytes = fetch(&url, asset.max_bytes()).map_err(|source| VerifyError::Fetch { + url: url.clone(), + source, + })?; + let signature_url = asset.signature_url(); + let asc = fetch(&signature_url, MAX_SIGNATURE_BYTES).map_err(|source| VerifyError::Fetch { + url: signature_url, + source, + })?; + + let artifact = authenticate(&bytes, &asc, asset, key)?; + + // The cache only ever receives material that already authenticated, so a later run cannot be + // handed bytes this run would have rejected. Caching is best effort. + if let Some((artifact_path, signature_path, dir)) = &cached_paths { + if let Err(err) = cache_pair(dir, artifact_path, &bytes, signature_path, &asc) { + warn!("failed to cache verified {}: {err}", asset.name()); + } + } + + Ok(artifact) +} + +/// Authenticates in-memory artifact bytes: digest pin first (when known), then signature. +pub fn authenticate( + bytes: &[u8], + armored_sig: &[u8], + asset: &ReleaseAsset, + key: &ReleaseKey, +) -> Result { + let sha256: [u8; 32] = Sha256::digest(bytes).into(); + + if let Some(expected) = asset.sha256() { + if sha256 != expected { + return Err(VerifyError::DigestMismatch { + name: asset.name().to_owned(), + expected: hex::encode(expected), + actual: hex::encode(sha256), + }); + } + } + + verify_detached_signature(bytes, armored_sig, key)?; + + Ok(VerifiedArtifact { + name: asset.name().to_owned(), + bytes: bytes.to_vec(), + sha256, + }) +} + +/// Decompresses and parses authenticated genesis bytes. +/// +/// Takes a [`VerifiedArtifact`] rather than a byte slice so that unauthenticated input cannot reach +/// the parser by accident. +pub fn parse_genesis_gz(artifact: &VerifiedArtifact) -> Result { + let json = decompress_gz(&artifact.bytes, &artifact.name)?; + serde_json::from_slice::(&json) + .map_err(|err| VerifyError::GenesisParse(err.to_string())) +} + +/// Decompresses authenticated gzip bytes under the JSON size cap. +pub fn decompress_gz(bytes: &[u8], what: &str) -> Result> { + let decoder = flate2::read::GzDecoder::new(bytes); + let mut out = Vec::new(); + decoder + .take(MAX_GENESIS_JSON_BYTES + 1) + .read_to_end(&mut out) + .map_err(|err| VerifyError::Decompress(format!("{what}: {err}")))?; + if out.len() as u64 > MAX_GENESIS_JSON_BYTES { + return Err(VerifyError::TooLarge { + what: format!("decompressed {what}"), + limit: MAX_GENESIS_JSON_BYTES as usize, + }); + } + Ok(out) +} + +/// Reads a cached artifact / signature pair, or `None` when either side is missing or unreadable. +fn read_cached_pair( + artifact_path: &Path, + signature_path: &Path, + asset: &ReleaseAsset, +) -> Option<(Vec, Vec)> { + let bytes = read_capped(artifact_path, asset.max_bytes()).ok()?; + let asc = read_capped(signature_path, MAX_SIGNATURE_BYTES).ok()?; + Some((bytes, asc)) +} + +/// Reads at most `max_bytes` from `path`, failing if the file is larger. +pub fn read_capped(path: &Path, max_bytes: usize) -> Result> { + let file = fs::File::open(path).map_err(|source| VerifyError::Io { + path: path.display().to_string(), + source, + })?; + let mut buf = Vec::new(); + file.take(max_bytes as u64 + 1) + .read_to_end(&mut buf) + .map_err(|source| VerifyError::Io { + path: path.display().to_string(), + source, + })?; + if buf.len() > max_bytes { + return Err(VerifyError::TooLarge { + what: path.display().to_string(), + limit: max_bytes, + }); + } + Ok(buf) +} + +fn cache_pair( + cache_dir: &Path, + artifact_path: &Path, + bytes: &[u8], + signature_path: &Path, + asc: &[u8], +) -> Result<()> { + fs::create_dir_all(cache_dir).map_err(|source| VerifyError::Io { + path: cache_dir.display().to_string(), + source, + })?; + write_atomic(artifact_path, bytes)?; + write_atomic(signature_path, asc) +} + +/// Writes `bytes` to `path` atomically (write to temp, then rename). +pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + + let io_err = |path: &Path| { + let path = path.display().to_string(); + move |source| VerifyError::Io { + path: path.clone(), + source, + } + }; + + let (tmp, mut file) = loop { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let mut tmp_name = OsString::from(path.as_os_str()); + tmp_name.push(format!(".{}.{}.tmp", std::process::id(), id)); + let tmp = PathBuf::from(tmp_name); + match OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(file) => break (tmp, file), + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(source) => return Err(io_err(&tmp)(source)), + } + }; + + let write_result = (|| { + file.write_all(bytes).map_err(io_err(&tmp))?; + file.sync_all().map_err(io_err(&tmp))?; + drop(file); + fs::rename(&tmp, path).map_err(io_err(path)) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&tmp); + } + write_result +} diff --git a/crates/release-verify/src/manifest.rs b/crates/release-verify/src/manifest.rs new file mode 100644 index 000000000..94fc22518 --- /dev/null +++ b/crates/release-verify/src/manifest.rs @@ -0,0 +1,129 @@ +use crate::error::{Result, VerifyError}; +use std::collections::BTreeMap; + +/// The digest manifest published (and signed) alongside a release. +/// +/// Format, as produced by `.github/workflows/release.yml`: +/// +/// ```text +/// version=v1.3.2 +/// commit=d6d8d2e739f50daa8174b299cb5170a9ce7e7974 +/// +/// [raw] +/// 4fcdd361… ./crates/genesis/genesis-devnet.json +/// +/// [compressed] +/// 92704da9… ./artifacts/genesis-v1.3.2.json.gz +/// ``` +/// +/// Only the file name is significant, so a manifest stays usable regardless of the directory +/// layout the release job happened to run `sha256sum` from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseManifest { + version: String, + commit: String, + digests: BTreeMap, +} + +impl ReleaseManifest { + /// Parses a manifest. The caller must have authenticated `bytes` first. + pub fn parse(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|err| VerifyError::Manifest(format!("not valid UTF-8: {err}")))?; + + let mut version = None; + let mut commit = None; + let mut digests = BTreeMap::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('[') { + continue; + } + if let Some(value) = line.strip_prefix("version=") { + if version.is_some() { + return Err(VerifyError::Manifest("multiple version= lines".to_owned())); + } + version = Some(value.to_owned()); + continue; + } + if let Some(value) = line.strip_prefix("commit=") { + if commit.is_some() { + return Err(VerifyError::Manifest("multiple commit= lines".to_owned())); + } + commit = Some(value.to_owned()); + continue; + } + + let (digest, path) = line + .split_once(char::is_whitespace) + .ok_or_else(|| VerifyError::Manifest(format!("unrecognised line {line:?}")))?; + let digest: [u8; 32] = hex::decode(digest) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + VerifyError::Manifest(format!("{line:?} does not start with a sha256")) + })?; + let name = path + .trim() + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| VerifyError::Manifest(format!("{line:?} names no file")))?; + + if let Some(previous) = digests.insert(name.to_owned(), digest) { + // Two different digests for one name would let a reader pick the convenient one. + if previous != digest { + return Err(VerifyError::Manifest(format!( + "conflicting digests listed for {name}" + ))); + } + } + } + + Ok(Self { + version: version.ok_or_else(|| VerifyError::Manifest("no version= line".to_owned()))?, + commit: commit.ok_or_else(|| VerifyError::Manifest("no commit= line".to_owned()))?, + digests, + }) + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn commit(&self) -> &str { + &self.commit + } + + pub fn digest_for(&self, asset_name: &str) -> Option<[u8; 32]> { + self.digests.get(asset_name).copied() + } + + /// Requires that this manifest belongs to `tag` and vouches for `asset_name` at `sha256`. + /// + /// The version check is what stops a manifest from another release — or another network's + /// release line — from being replayed against this artifact. + pub fn check(&self, tag: &str, asset_name: &str, sha256: &[u8; 32]) -> Result<()> { + if self.version != tag { + return Err(VerifyError::Manifest(format!( + "manifest is for release {}, expected {tag}", + self.version + ))); + } + + let expected = self + .digest_for(asset_name) + .ok_or_else(|| VerifyError::Manifest(format!("does not list {asset_name}")))?; + + if &expected != sha256 { + return Err(VerifyError::DigestMismatch { + name: asset_name.to_owned(), + expected: hex::encode(expected), + actual: hex::encode(sha256), + }); + } + + Ok(()) + } +} diff --git a/crates/release-verify/src/signature.rs b/crates/release-verify/src/signature.rs new file mode 100644 index 000000000..92e9c657f --- /dev/null +++ b/crates/release-verify/src/signature.rs @@ -0,0 +1,138 @@ +use crate::{ + error::{Result, VerifyError}, + key::{primary_can_sign_at, subkey_can_sign_at, ReleaseKey}, +}; +use pgp::{ + composed::{Deserializable as _, DetachedSignature, SignedPublicKey, SignedPublicSubKey}, + crypto::hash::HashAlgorithm, + packet::SignatureType, + types::{Fingerprint, KeyDetails as _, KeyId}, +}; +use std::io::Cursor; + +/// Digest algorithms accepted on a release signature. SHA-1 and friends are rejected outright. +pub(crate) const ACCEPTED_HASH_ALGORITHMS: &[HashAlgorithm] = &[ + HashAlgorithm::Sha256, + HashAlgorithm::Sha384, + HashAlgorithm::Sha512, + HashAlgorithm::Sha3_256, + HashAlgorithm::Sha3_512, +]; + +/// Verifies a detached OpenPGP signature over `data` against the pinned release key. +/// +/// Beyond the cryptographic check this rejects signatures that are structurally unusable: text mode +/// signatures (which do not bind the exact bytes), weak digests, and signatures whose issuer is not +/// a signing-capable component of the pinned certificate. +pub fn verify_detached_signature(data: &[u8], armored_sig: &[u8], key: &ReleaseKey) -> Result<()> { + let (detached, _headers) = DetachedSignature::from_armor_single(Cursor::new(armored_sig)) + .map_err(|err| VerifyError::SignatureParse(err.to_string()))?; + let sig = &detached.signature; + + match sig.typ() { + Some(SignatureType::Binary) => {} + Some(other) => return Err(VerifyError::SignatureType(format!("{other:?}"))), + None => return Err(VerifyError::SignatureType("unknown".to_owned())), + } + + let hash_alg = sig + .hash_alg() + .ok_or_else(|| VerifyError::WeakDigest("unspecified".to_owned()))?; + if !ACCEPTED_HASH_ALGORITHMS.contains(&hash_alg) { + return Err(VerifyError::WeakDigest(format!("{hash_alg:?}"))); + } + + let now = pgp::types::Timestamp::now().as_secs() as u64; + if let Some(created) = sig.created().map(|created| created.as_secs() as u64) { + let expired = sig.signature_expiration_time().is_some_and(|lifetime| { + lifetime.as_secs() != 0 && created.saturating_add(lifetime.as_secs() as u64) <= now + }); + if created > now || expired { + return Err(VerifyError::KeyPolicy( + "the detached signature is not currently valid".to_owned(), + )); + } + } else { + return Err(VerifyError::KeyPolicy( + "the detached signature has no creation time".to_owned(), + )); + } + + let issuer_fingerprints: Vec<&Fingerprint> = sig.issuer_fingerprint(); + let issuer_key_ids: Vec<&KeyId> = sig.issuer_key_id(); + if issuer_fingerprints.is_empty() && issuer_key_ids.is_empty() { + return Err(VerifyError::MissingIssuer); + } + + let candidates = signing_candidates_at(key.cert(), now); + if candidates.is_empty() { + return Err(VerifyError::KeyPolicy( + "the release key has no currently valid signing component".to_owned(), + )); + } + for candidate in candidates { + let is_issuer = issuer_fingerprints + .iter() + .any(|fpr| **fpr == candidate.fingerprint()) + || issuer_key_ids + .iter() + .any(|id| **id == candidate.legacy_key_id()); + if !is_issuer { + continue; + } + return candidate + .verify(&detached, data) + .map_err(|err| VerifyError::BadSignature(err.to_string())); + } + + Err(VerifyError::UntrustedIssuer) +} + +/// A component of the release certificate that may have produced a data signature. +pub(crate) enum SigningCandidate<'a> { + Primary(&'a pgp::packet::PublicKey), + Subkey(&'a SignedPublicSubKey), +} + +impl SigningCandidate<'_> { + pub(crate) fn fingerprint(&self) -> Fingerprint { + match self { + Self::Primary(key) => key.fingerprint(), + Self::Subkey(key) => key.fingerprint(), + } + } + + pub(crate) fn legacy_key_id(&self) -> KeyId { + match self { + Self::Primary(key) => key.legacy_key_id(), + Self::Subkey(key) => key.legacy_key_id(), + } + } + + fn verify(&self, sig: &DetachedSignature, data: &[u8]) -> pgp::errors::Result<()> { + match self { + Self::Primary(key) => sig.verify(*key, data), + Self::Subkey(key) => sig.verify(*key, data), + } + } +} + +/// The primary key plus every signing-capable subkey of `cert`. +#[cfg(test)] +pub(crate) fn signing_candidates(cert: &SignedPublicKey) -> Vec> { + signing_candidates_at(cert, pgp::types::Timestamp::now().as_secs() as u64) +} + +fn signing_candidates_at(cert: &SignedPublicKey, now: u64) -> Vec> { + let mut candidates = Vec::new(); + if primary_can_sign_at(cert, now) { + candidates.push(SigningCandidate::Primary(&cert.primary_key)); + } + candidates.extend( + cert.public_subkeys + .iter() + .filter(|subkey| subkey_can_sign_at(subkey, now)) + .map(SigningCandidate::Subkey), + ); + candidates +} diff --git a/crates/release-verify/src/test_support.rs b/crates/release-verify/src/test_support.rs new file mode 100644 index 000000000..d201942ba --- /dev/null +++ b/crates/release-verify/src/test_support.rs @@ -0,0 +1,162 @@ +//! Fixtures for exercising the fail-closed paths from other crates' tests. +//! +//! Enabled by this crate's own tests and by the `test-support` feature, so a consumer can build a +//! throwaway release — a key, an artifact, and a signature over it — without re-implementing any of +//! it. Never enable `test-support` outside `[dev-dependencies]`. + +use crate::{ + error::FetchError, + key::{fingerprint_hex, ReleaseKey}, + ReleaseAsset, +}; +use flate2::{write::GzEncoder, Compression}; +use pgp::{ + composed::{DetachedSignature, KeyType, SecretKeyParamsBuilder, SignedSecretKey}, + crypto::hash::HashAlgorithm, + types::{KeyDetails as _, Password}, +}; +use std::{cell::RefCell, fs, io::Write as _, path::Path}; + +/// Gzips `payload` the way the release workflow packs an artifact. +pub fn gzip(payload: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(payload).unwrap(); + encoder.finish().unwrap() +} + +/// A throwaway release identity: secret key, armored certificate, and its fingerprint. +pub struct TestKey { + secret: SignedSecretKey, + /// The armored public certificate, as it would be embedded in a verifier. + pub armored: String, + /// Uppercase hex fingerprint of the certificate. + pub fingerprint: String, +} + +impl TestKey { + pub fn new(user_id: &str) -> Self { + let params = SecretKeyParamsBuilder::default() + .key_type(KeyType::Ed25519Legacy) + .can_certify(true) + .can_sign(true) + .primary_user_id(user_id.into()) + .build() + .expect("key params"); + let secret = params.generate(rand_08::rngs::OsRng).expect("generate key"); + let public = secret.to_public_key(); + let armored = public + .to_armored_string(Default::default()) + .expect("armor public key"); + let fingerprint = fingerprint_hex(&public.fingerprint()); + Self { + secret, + armored, + fingerprint, + } + } + + /// The identity a test treats as the real release signer. + pub fn release() -> Self { + Self::new("Release ") + } + + /// An identity a test treats as hostile. + pub fn attacker() -> Self { + Self::new("Attacker ") + } + + /// A [`ReleaseKey`] pinned to this identity. + pub fn key(&self) -> ReleaseKey { + ReleaseKey::new(&self.armored, &self.fingerprint).expect("test key must load") + } + + /// A detached binary SHA-256 signature over `data`. + pub fn sign(&self, data: &[u8]) -> Vec { + self.sign_with(SignatureMode::Binary, data) + } + + /// A detached text-mode signature over `data`, which verification must refuse. + pub fn sign_text(&self, data: &[u8]) -> Vec { + self.sign_with(SignatureMode::Text, data) + } + + fn sign_with(&self, mode: SignatureMode, data: &[u8]) -> Vec { + let sign = match mode { + SignatureMode::Binary => DetachedSignature::sign_binary_data, + SignatureMode::Text => DetachedSignature::sign_text_data, + }; + sign( + rand_08::rngs::OsRng, + &self.secret.primary_key, + &Password::empty(), + HashAlgorithm::Sha256, + data, + ) + .expect("sign") + .to_armored_bytes(Default::default()) + .expect("armor signature") + } +} + +enum SignatureMode { + Binary, + Text, +} + +/// A fetcher standing in for one release's asset pair, counting how often it was called. +pub struct FakeRelease { + assets: RefCell)>>, + calls: RefCell, +} + +impl Default for FakeRelease { + fn default() -> Self { + Self::new() + } +} + +impl FakeRelease { + pub fn new() -> Self { + Self { + assets: RefCell::new(Vec::new()), + calls: RefCell::new(0), + } + } + + /// Publishes `bytes` at `asset`'s URL and `asc` at its signature URL. + pub fn publish(self, asset: &ReleaseAsset, bytes: Vec, asc: Vec) -> Self { + self.assets.borrow_mut().push((asset.url(), bytes)); + self.assets.borrow_mut().push((asset.signature_url(), asc)); + self + } + + pub fn fetch(&self, url: &str, max_bytes: usize) -> Result, FetchError> { + *self.calls.borrow_mut() += 1; + let body = self + .assets + .borrow() + .iter() + .find(|(published, _)| published == url) + .map(|(_, bytes)| bytes.clone()) + .ok_or_else(|| FetchError::not_found(format!("404 for {url}")))?; + if body.len() > max_bytes { + return Err(FetchError::new(format!("{url} is over the byte limit"))); + } + Ok(body) + } + + pub fn calls(&self) -> usize { + *self.calls.borrow() + } +} + +/// A fetcher that always fails, standing in for an unreachable release. +pub fn offline(_url: &str, _max_bytes: usize) -> Result, FetchError> { + Err(FetchError::new("network unavailable")) +} + +/// Plants an asset pair in `dir` as if an earlier run had cached it. +pub fn plant_cache(dir: &Path, asset: &ReleaseAsset, bytes: &[u8], asc: &[u8]) { + fs::write(dir.join(asset.name()), bytes).unwrap(); + fs::write(dir.join(asset.signature_name()), asc).unwrap(); +} diff --git a/crates/release-verify/src/tests.rs b/crates/release-verify/src/tests.rs new file mode 100644 index 000000000..f343b1821 --- /dev/null +++ b/crates/release-verify/src/tests.rs @@ -0,0 +1,722 @@ +use crate::{ + asset::ReleaseAsset, + error::VerifyError, + key::{validate_certificate_policy_at, ReleaseKey, FLUENT_RELEASE_KEY_FINGERPRINT}, + load::{authenticate, load_verified, parse_genesis_gz, read_capped, VerifiedArtifact}, + manifest::ReleaseManifest, + signature::{signing_candidates, verify_detached_signature, ACCEPTED_HASH_ALGORITHMS}, + test_support::{gzip, offline, plant_cache, FakeRelease, TestKey}, + MAX_SIGNATURE_BYTES, +}; +use pgp::{ + composed::{Deserializable as _, DetachedSignature, SignedPublicKey}, + crypto::hash::HashAlgorithm, + packet::{Signature, SignatureType}, +}; +use sha2::Digest as _; +use std::{ + fs, + io::Cursor, + sync::{Arc, Barrier}, +}; + +/// Minimal but valid genesis JSON, gzipped, used as a stand-in for a release artifact. +fn sample_genesis_gz() -> Vec { + gzip(br#"{"config":{"chainId":1337},"alloc":{},"gasLimit":"0x1c9c380","difficulty":"0x0"}"#) +} + +/// A genesis an attacker would like the caller to use instead. +fn substituted_genesis_gz() -> Vec { + gzip(br#"{"config":{"chainId":31337},"alloc":{},"gasLimit":"0x1","difficulty":"0x0"}"#) +} + +fn armor(sig: DetachedSignature) -> Vec { + sig.to_armored_bytes(Default::default()) + .expect("armor signature") +} + +fn parse_sig(asc: &[u8]) -> DetachedSignature { + DetachedSignature::from_armor_single(Cursor::new(asc)) + .expect("parse signature") + .0 +} + +fn asset() -> ReleaseAsset { + ReleaseAsset::genesis("v9.9.9", None).expect("valid test asset") +} + +#[test] +fn release_asset_rejects_path_and_url_metacharacters() { + for tag in ["", "../v1.0.0", "v1.0.0?download=1", "release/name"] { + let err = ReleaseAsset::genesis(tag.to_owned(), None) + .expect_err("an invalid release tag must be rejected before URL construction"); + assert!(matches!(err, VerifyError::Asset(_)), "{tag:?}: {err}"); + } + + for channel in ["../mainnet", "mainnet#fragment", "main/net"] { + let err = ReleaseAsset::genesis("v1.0.0", Some(channel)) + .expect_err("an invalid channel must be rejected before filename construction"); + assert!(matches!(err, VerifyError::Asset(_)), "{channel:?}: {err}"); + } + + for name in ["../artifact", "/tmp/artifact", "artifact?raw=1"] { + let err = ReleaseAsset::new("v1.0.0", name.to_owned()) + .expect_err("an invalid asset name must be rejected before cache construction"); + assert!(matches!(err, VerifyError::Asset(_)), "{name:?}: {err}"); + } +} + +#[test] +fn concurrent_atomic_writers_do_not_share_a_temporary_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("artifact"); + let barrier = Arc::new(Barrier::new(3)); + let payloads = [vec![0x11; 1024 * 1024], vec![0x22; 1024 * 1024]]; + let expected_payloads = payloads.clone(); + let writers: Vec<_> = payloads + .into_iter() + .map(|payload| { + let path = path.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + crate::write_atomic(&path, &payload) + }) + }) + .collect(); + + barrier.wait(); + for writer in writers { + writer + .join() + .expect("writer thread must not panic") + .expect("concurrent atomic write must succeed"); + } + + let stored = fs::read(path).unwrap(); + assert!(expected_payloads + .iter() + .any(|payload| payload.as_slice() == stored.as_slice())); +} + +fn verified(bytes: Vec) -> VerifiedArtifact { + VerifiedArtifact { + name: "genesis-v9.9.9.json.gz".to_owned(), + sha256: <[u8; 32]>::from(sha2::Sha256::digest(&bytes)), + bytes, + } +} + +// ------------------------------------------------------------------------- +// The pinned release key +// ------------------------------------------------------------------------- + +#[test] +fn embedded_release_key_parses_and_matches_its_pin() { + let key = ReleaseKey::fluent().expect("embedded key must load"); + assert_eq!(key.fingerprint(), FLUENT_RELEASE_KEY_FINGERPRINT); + assert!( + !signing_candidates(key.cert()).is_empty(), + "embedded key must expose at least one signing component" + ); +} + +#[test] +fn release_key_that_misses_its_fingerprint_pin_is_rejected() { + let impostor = TestKey::new("Impostor "); + let err = ReleaseKey::new(&impostor.armored, FLUENT_RELEASE_KEY_FINGERPRINT) + .expect_err("must reject a key that does not match the pin"); + assert!(matches!(err, VerifyError::KeyFingerprint { .. }), "{err}"); +} + +#[test] +fn malformed_release_key_is_rejected() { + let err = ReleaseKey::new("not a pgp key at all", FLUENT_RELEASE_KEY_FINGERPRINT) + .expect_err("must reject garbage key"); + assert!(matches!(err, VerifyError::KeyParse(_)), "{err}"); +} + +#[test] +fn release_key_policy_rejects_a_revoked_primary_key() { + let release = TestKey::release(); + let (mut cert, _) = SignedPublicKey::from_string(&release.armored).unwrap(); + let simulated_revocation = cert.details.users[0].signatures[0].clone(); + cert.details + .revocation_signatures + .push(simulated_revocation); + + let err = validate_certificate_policy_at(&cert, u64::MAX) + .expect_err("a certificate snapshot containing a primary revocation must be rejected"); + assert!(matches!(err, VerifyError::KeyPolicy(_)), "{err}"); +} + +// ------------------------------------------------------------------------- +// Signature verification +// ------------------------------------------------------------------------- + +#[test] +fn valid_signature_is_accepted() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + let sig = release.sign(&data); + + verify_detached_signature(&data, &sig, &release.key()).expect("valid signature must verify"); +} + +#[test] +fn signature_over_different_content_is_rejected() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + let sig = release.sign(&data); + + let mut tampered = data.clone(); + *tampered.last_mut().unwrap() ^= 0x01; + + let err = verify_detached_signature(&tampered, &sig, &release.key()) + .expect_err("content mismatch must be rejected"); + assert!(matches!(err, VerifyError::BadSignature(_)), "{err}"); +} + +#[test] +fn signature_from_a_different_key_is_rejected() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let data = sample_genesis_gz(); + + let err = verify_detached_signature(&data, &attacker.sign(&data), &release.key()) + .expect_err("wrong-key signature must be rejected"); + assert!(matches!(err, VerifyError::UntrustedIssuer), "{err}"); +} + +#[test] +fn signature_with_a_spoofed_issuer_is_rejected() { + // Issuer subpackets are attacker-controlled hints; only the cryptographic check counts. + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let data = sample_genesis_gz(); + + let mut forged = parse_sig(&attacker.sign(&data)); + let genuine = parse_sig(&release.sign(&data)); + let genuine_issuers = genuine + .signature + .config() + .expect("config") + .unhashed_subpackets + .clone(); + for (idx, subpacket) in genuine_issuers.into_iter().enumerate() { + forged + .signature + .unhashed_subpacket_insert(idx, subpacket) + .expect("insert issuer subpacket"); + } + + let err = verify_detached_signature(&data, &armor(forged), &release.key()) + .expect_err("a forged issuer must not make a bad signature verify"); + assert!( + matches!( + err, + VerifyError::BadSignature(_) | VerifyError::UntrustedIssuer + ), + "{err}" + ); +} + +#[test] +fn empty_and_malformed_signatures_are_rejected() { + let release = TestKey::release(); + let key = release.key(); + let data = sample_genesis_gz(); + + for sig in [ + b"".to_vec(), + b"not a signature".to_vec(), + b"-----BEGIN PGP SIGNATURE-----\n\nzzzz\n-----END PGP SIGNATURE-----\n".to_vec(), + ] { + let err = verify_detached_signature(&data, &sig, &key) + .expect_err("malformed signature must be rejected"); + assert!(matches!(err, VerifyError::SignatureParse(_)), "{err}"); + } +} + +#[test] +fn truncated_signature_is_rejected() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + let mut sig = release.sign(&data); + sig.truncate(sig.len() / 2); + + verify_detached_signature(&data, &sig, &release.key()) + .expect_err("truncated signature must be rejected"); +} + +#[test] +fn public_key_block_in_place_of_a_signature_is_rejected() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + + verify_detached_signature(&data, release.armored.as_bytes(), &release.key()) + .expect_err("a certificate is not a detached signature"); +} + +#[test] +fn weak_digest_signature_is_rejected() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + + // rPGP refuses to *produce* a SHA-1 EdDSA signature, so downgrade a genuine one the way an + // attacker betting on a weak digest would. + let genuine = parse_sig(&release.sign(&data)); + let mut config = genuine.signature.config().expect("config").clone(); + config.hash_alg = HashAlgorithm::Sha1; + let weak = Signature::from_config( + config, + genuine.signature.signed_hash_value().expect("hash value"), + genuine.signature.signature().expect("sig bytes").clone(), + ) + .expect("build downgraded signature"); + + let err = + verify_detached_signature(&data, &armor(DetachedSignature::new(weak)), &release.key()) + .expect_err("SHA-1 signature must be rejected"); + assert!(matches!(err, VerifyError::WeakDigest(_)), "{err}"); +} + +#[test] +fn text_mode_signature_is_rejected() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + + let err = verify_detached_signature(&data, &release.sign_text(&data), &release.key()) + .expect_err("text mode signature must be rejected"); + assert!(matches!(err, VerifyError::SignatureType(_)), "{err}"); +} + +// ------------------------------------------------------------------------- +// Digest pinning +// ------------------------------------------------------------------------- + +#[test] +fn digest_pin_mismatch_is_rejected_even_with_a_valid_signature() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + let sig = release.sign(&data); + + let err = authenticate( + &data, + &sig, + &asset().with_sha256([0x11; 32]), + &release.key(), + ) + .expect_err("digest pin must apply"); + assert!(matches!(err, VerifyError::DigestMismatch { .. }), "{err}"); +} + +#[test] +fn matching_digest_pin_is_accepted() { + let release = TestKey::release(); + let data = sample_genesis_gz(); + let sig = release.sign(&data); + let digest: [u8; 32] = sha2::Sha256::digest(&data).into(); + + let artifact = authenticate(&data, &sig, &asset().with_sha256(digest), &release.key()) + .expect("pinned digest must verify"); + assert_eq!(artifact.sha256, digest); + assert_eq!(artifact.bytes, data); +} + +// ------------------------------------------------------------------------- +// Cache handling +// ------------------------------------------------------------------------- + +#[test] +fn download_verifies_then_caches_and_reuses_the_cache() { + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let bytes = sample_genesis_gz(); + let asc = release.sign(&bytes); + let digest: [u8; 32] = sha2::Sha256::digest(&bytes).into(); + let asset = asset().with_sha256(digest); + let feed = FakeRelease::new().publish(&asset, bytes.clone(), asc.clone()); + + let artifact = load_verified(Some(dir.path()), &asset, &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect("first load must succeed"); + assert_eq!(artifact.bytes, bytes); + assert_eq!(feed.calls(), 2); + assert_eq!(fs::read(dir.path().join(asset.name())).unwrap(), bytes); + assert_eq!( + fs::read(dir.path().join(asset.signature_name())).unwrap(), + asc + ); + + // Second load is served from cache: the fetcher is not touched again. + load_verified(Some(dir.path()), &asset, &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect("cached load must succeed"); + assert_eq!(feed.calls(), 2); +} + +#[test] +fn replaced_cache_is_discarded_and_refetched() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let bytes = sample_genesis_gz(); + let asset = asset(); + let feed = FakeRelease::new().publish(&asset, bytes.clone(), release.sign(&bytes)); + + let evil = substituted_genesis_gz(); + plant_cache(dir.path(), &asset, &evil, &attacker.sign(&evil)); + + let artifact = load_verified(Some(dir.path()), &asset, &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect("must fall back to the release"); + + // The substituted bytes never reach the caller, and the cache is repaired. + assert_eq!(artifact.bytes, bytes); + assert_eq!(parse_genesis_gz(&artifact).unwrap().config.chain_id, 1337); + assert_eq!(feed.calls(), 2); + assert_eq!(fs::read(dir.path().join(asset.name())).unwrap(), bytes); +} + +#[test] +fn replaced_cache_is_not_accepted_when_the_release_is_unreachable() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let asset = asset(); + + let evil = substituted_genesis_gz(); + plant_cache(dir.path(), &asset, &evil, &attacker.sign(&evil)); + + load_verified(Some(dir.path()), &asset, &release.key(), &offline) + .expect_err("an unauthenticated cache must never be used"); +} + +#[test] +fn same_name_cache_substitution_is_rejected() { + // The classic attack this guards: drop a file with the expected name into the cache directory + // and let the next run pick it up. + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let asset = asset(); + let genuine = sample_genesis_gz(); + + // A genuine signature, but next to bytes it does not cover. + plant_cache( + dir.path(), + &asset, + &substituted_genesis_gz(), + &release.sign(&genuine), + ); + + let err = load_verified(Some(dir.path()), &asset, &release.key(), &offline) + .expect_err("a signature from another artifact must not authenticate these bytes"); + assert!(matches!(err, VerifyError::Fetch { .. }), "{err}"); + assert!( + !dir.path().join(asset.name()).exists(), + "the rejected cache entry must be gone" + ); +} + +#[test] +fn cache_with_a_missing_signature_is_not_accepted() { + let release = TestKey::release(); + let dir = tempfile::tempdir().unwrap(); + let asset = asset(); + let bytes = sample_genesis_gz(); + fs::write(dir.path().join(asset.name()), &bytes).unwrap(); + + // Cached data with no signature next to it is not trusted, even though the bytes are genuine. + load_verified(Some(dir.path()), &asset, &release.key(), &offline) + .expect_err("missing signature must fail closed"); + + // With the release reachable the same bytes are accepted only after re-fetching. + let feed = FakeRelease::new().publish(&asset, bytes.clone(), release.sign(&bytes)); + load_verified(Some(dir.path()), &asset, &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect("re-fetch must succeed"); + assert_eq!(feed.calls(), 2); +} + +#[test] +fn downloaded_artifact_that_fails_verification_is_not_cached() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let dir = tempfile::tempdir().unwrap(); + let asset = asset(); + let bytes = sample_genesis_gz(); + let feed = FakeRelease::new().publish(&asset, bytes.clone(), attacker.sign(&bytes)); + + load_verified(Some(dir.path()), &asset, &release.key(), &|url, max| { + feed.fetch(url, max) + }) + .expect_err("a badly signed download must fail closed"); + + assert!(!dir.path().join(asset.name()).exists()); + assert!(!dir.path().join(asset.signature_name()).exists()); +} + +#[test] +fn loading_without_a_cache_still_verifies() { + let release = TestKey::release(); + let attacker = TestKey::attacker(); + let asset = asset(); + let bytes = sample_genesis_gz(); + + let good = FakeRelease::new().publish(&asset, bytes.clone(), release.sign(&bytes)); + load_verified(None, &asset, &release.key(), &|url, max| { + good.fetch(url, max) + }) + .expect("uncached load must succeed"); + + let bad = FakeRelease::new().publish(&asset, bytes.clone(), attacker.sign(&bytes)); + load_verified(None, &asset, &release.key(), &|url, max| { + bad.fetch(url, max) + }) + .expect_err("uncached load must still fail closed"); +} + +#[test] +fn oversized_cached_artifact_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let asset = asset(); + plant_cache( + dir.path(), + &asset, + &sample_genesis_gz(), + &vec![0u8; MAX_SIGNATURE_BYTES + 1], + ); + + let err = read_capped( + &dir.path().join(asset.signature_name()), + MAX_SIGNATURE_BYTES, + ) + .expect_err("oversized signature must be rejected"); + assert!(matches!(err, VerifyError::TooLarge { .. }), "{err}"); +} + +// ------------------------------------------------------------------------- +// Release manifest +// ------------------------------------------------------------------------- + +const SAMPLE_MANIFEST: &str = "version=v1.3.2 +commit=d6d8d2e739f50daa8174b299cb5170a9ce7e7974 + +[raw] +4fcdd3610da44467e709972b638097cd2bdb5377dd8adb792e8c2328c1e41dc0 ./crates/genesis/genesis-devnet.json + +[compressed] +92704da98369998447aae3a4bca614b1e1b5f989e9c05f12d300dc933ffade73 ./artifacts/genesis-v1.3.2.json.gz +3086b3bf0ceeb1ea7ebb5ea5a523a12df72c100f2d723fcf75486a7e1a7382d1 ./artifacts/genesis-mainnet-v1.3.2.json.gz +"; + +fn sample_manifest() -> ReleaseManifest { + ReleaseManifest::parse(SAMPLE_MANIFEST.as_bytes()).expect("manifest must parse") +} + +fn digest(hex_digest: &str) -> [u8; 32] { + hex::decode(hex_digest).unwrap().try_into().unwrap() +} + +#[test] +fn manifest_parses_release_metadata_and_digests() { + let manifest = sample_manifest(); + assert_eq!(manifest.version(), "v1.3.2"); + assert_eq!( + manifest.commit(), + "d6d8d2e739f50daa8174b299cb5170a9ce7e7974" + ); + assert_eq!( + manifest.digest_for("genesis-v1.3.2.json.gz"), + Some(digest( + "92704da98369998447aae3a4bca614b1e1b5f989e9c05f12d300dc933ffade73" + )) + ); + assert_eq!(manifest.digest_for("genesis-v9.9.9.json.gz"), None); +} + +#[test] +fn manifest_accepts_the_artifact_it_vouches_for() { + sample_manifest() + .check( + "v1.3.2", + "genesis-v1.3.2.json.gz", + &digest("92704da98369998447aae3a4bca614b1e1b5f989e9c05f12d300dc933ffade73"), + ) + .expect("listed digest must be accepted"); +} + +#[test] +fn manifest_rejects_a_modified_artifact() { + let err = sample_manifest() + .check("v1.3.2", "genesis-v1.3.2.json.gz", &[0x11; 32]) + .expect_err("modified artifact must be rejected"); + assert!(matches!(err, VerifyError::DigestMismatch { .. }), "{err}"); +} + +#[test] +fn manifest_from_another_release_is_rejected() { + // Replaying a validly signed manifest from a different release must not bind this one. + let err = sample_manifest() + .check( + "v1.3.3", + "genesis-v1.3.2.json.gz", + &digest("92704da98369998447aae3a4bca614b1e1b5f989e9c05f12d300dc933ffade73"), + ) + .expect_err("manifest for another release must be rejected"); + assert!(matches!(err, VerifyError::Manifest(_)), "{err}"); +} + +#[test] +fn manifest_that_does_not_list_the_asset_is_rejected() { + // The mainnet/devnet split lives in the file name, so asking for the wrong network's asset + // against a manifest that omits it must fail rather than fall through. + let err = sample_manifest() + .check("v1.3.2", "genesis-testnet-v1.3.2.json.gz", &[0x11; 32]) + .expect_err("unlisted asset must be rejected"); + assert!(matches!(err, VerifyError::Manifest(_)), "{err}"); +} + +#[test] +fn malformed_manifests_are_rejected() { + for bad in [ + "", + "commit=abc\n", + "version=v1\n", + "version=v1\ncommit=abc\nnot-a-digest ./artifacts/x.gz\n", + "version=v1\ncommit=abc\nzzzz ./artifacts/x.gz\n", + ] { + ReleaseManifest::parse(bad.as_bytes()).expect_err(&format!("must reject manifest {bad:?}")); + } +} + +#[test] +fn manifest_with_conflicting_digests_for_one_name_is_rejected() { + let manifest = format!( + "version=v1\ncommit=abc\n{} ./a/x.gz\n{} ./b/x.gz\n", + hex::encode([0x11; 32]), + hex::encode([0x22; 32]) + ); + let err = ReleaseManifest::parse(manifest.as_bytes()) + .expect_err("conflicting digests must be rejected"); + assert!(matches!(err, VerifyError::Manifest(_)), "{err}"); +} + +#[test] +fn manifest_with_duplicate_release_metadata_is_rejected() { + for duplicate in [ + "version=v1.3.2\nversion=v1.3.2\ncommit=deadbeef\n", + "version=v1.3.2\ncommit=deadbeef\ncommit=deadbeef\n", + ] { + let err = ReleaseManifest::parse(duplicate.as_bytes()) + .expect_err("release metadata must be unique even when repeated values agree"); + assert!(matches!(err, VerifyError::Manifest(_)), "{err}"); + } +} + +// ------------------------------------------------------------------------- +// Genesis parsing +// ------------------------------------------------------------------------- + +#[test] +fn authenticated_genesis_parses() { + let genesis = parse_genesis_gz(&verified(sample_genesis_gz())).expect("must parse"); + assert_eq!(genesis.config.chain_id, 1337); +} + +#[test] +fn non_gzip_and_non_json_payloads_are_rejected() { + parse_genesis_gz(&verified(b"not gzip".to_vec())).expect_err("must reject non-gzip"); + parse_genesis_gz(&verified(gzip(b"not json"))).expect_err("must reject non-json"); +} + +// ------------------------------------------------------------------------- +// The published artifacts +// ------------------------------------------------------------------------- + +/// Detached signatures actually published by the release workflow. +const PUBLISHED_SIGNATURES: &[(&str, &[u8])] = &[ + ( + "genesis-v0.5.7.json.gz", + include_bytes!("../testdata/genesis-v0.5.7.json.gz.asc"), + ), + ( + "genesis-v0.3.4-dev.json.gz", + include_bytes!("../testdata/genesis-v0.3.4-dev.json.gz.asc"), + ), + ( + "genesis-mainnet-v1.0.0.json.gz", + include_bytes!("../testdata/genesis-mainnet-v1.0.0.json.gz.asc"), + ), + ( + "genesis-manifest-v1.3.2.txt", + include_bytes!("../testdata/genesis-manifest-v1.3.2.txt.asc"), + ), +]; + +/// Ties the pinned key to production: every published signature must be a binary, strong-digest +/// signature issued by the key embedded in this crate. +#[test] +fn published_signatures_are_issued_by_the_pinned_release_key() { + let key = ReleaseKey::fluent().expect("embedded key must load"); + let candidates = signing_candidates(key.cert()); + + for (asset, asc) in PUBLISHED_SIGNATURES { + let detached = parse_sig(asc); + let sig = &detached.signature; + assert_eq!( + sig.typ(), + Some(SignatureType::Binary), + "{asset}: not a binary signature" + ); + assert!( + ACCEPTED_HASH_ALGORITHMS.contains(&sig.hash_alg().expect("digest algorithm")), + "{asset}: unaccepted digest" + ); + assert!( + candidates.iter().any(|candidate| { + sig.issuer_fingerprint() + .iter() + .any(|fpr| **fpr == candidate.fingerprint()) + || sig + .issuer_key_id() + .iter() + .any(|id| **id == candidate.legacy_key_id()) + }), + "{asset}: not issued by the pinned release key" + ); + } +} + +/// The real manifest, verbatim from the v1.3.2 release, must parse and bind its assets. +#[test] +fn published_manifest_parses_and_binds_its_assets() { + verify_detached_signature( + include_bytes!("../testdata/genesis-manifest-v1.3.2.txt"), + include_bytes!("../testdata/genesis-manifest-v1.3.2.txt.asc"), + &ReleaseKey::fluent().expect("embedded key must load"), + ) + .expect("the published manifest signature must verify against the pinned release key"); + + let manifest = + ReleaseManifest::parse(include_bytes!("../testdata/genesis-manifest-v1.3.2.txt")) + .expect("published manifest must parse"); + assert_eq!(manifest.version(), "v1.3.2"); + for asset in [ + "genesis-v1.3.2.json.gz", + "genesis-mainnet-v1.3.2.json.gz", + "evm-runtime-permissive-v1.3.2.rwasm.gz", + ] { + let digest = manifest + .digest_for(asset) + .unwrap_or_else(|| panic!("{asset} is not listed")); + manifest + .check("v1.3.2", asset, &digest) + .unwrap_or_else(|err| panic!("{asset}: {err}")); + } +} diff --git a/crates/release-verify/testdata/genesis-mainnet-v1.0.0.json.gz.asc b/crates/release-verify/testdata/genesis-mainnet-v1.0.0.json.gz.asc new file mode 100644 index 000000000..28b5d19e0 --- /dev/null +++ b/crates/release-verify/testdata/genesis-mainnet-v1.0.0.json.gz.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQQKbQXl3ZgGm6GE7YMEpo1iDVII/QUCabwZ/AAKCRAEpo1iDVII +/bqrAP9nYtIj+BkWI/y7iNks/WxVp8pKBmBpjajykWRbPJnoxgEAqEcfXUfMqxXl +LaIRC9LKRic4cs+oY7qHAEZ8JFb3Jgk= +=rSyD +-----END PGP SIGNATURE----- diff --git a/crates/release-verify/testdata/genesis-manifest-v1.3.2.txt b/crates/release-verify/testdata/genesis-manifest-v1.3.2.txt new file mode 100644 index 000000000..02f47326d --- /dev/null +++ b/crates/release-verify/testdata/genesis-manifest-v1.3.2.txt @@ -0,0 +1,12 @@ +version=v1.3.2 +commit=d6d8d2e739f50daa8174b299cb5170a9ce7e7974 + +[raw] +4fcdd3610da44467e709972b638097cd2bdb5377dd8adb792e8c2328c1e41dc0 ./crates/genesis/genesis-devnet.json +8d6443bdb150a5ff5a74fa81a9ce6d33d7d2276c508d2bff32b5e9e07e8bc0a0 ./crates/genesis/genesis-mainnet.json +50245996aeaebe6a47a87981d8e52f939f042257b10e0dcd341a900bddb50087 ./crates/genesis/evm-runtime-permissive.rwasm + +[compressed] +92704da98369998447aae3a4bca614b1e1b5f989e9c05f12d300dc933ffade73 ./artifacts/genesis-v1.3.2.json.gz +3086b3bf0ceeb1ea7ebb5ea5a523a12df72c100f2d723fcf75486a7e1a7382d1 ./artifacts/genesis-mainnet-v1.3.2.json.gz +b2374989ad77c54dda2c2b6252ff6fcbf86543cf57388558a881ae63b5c2a55a ./artifacts/evm-runtime-permissive-v1.3.2.rwasm.gz diff --git a/crates/release-verify/testdata/genesis-manifest-v1.3.2.txt.asc b/crates/release-verify/testdata/genesis-manifest-v1.3.2.txt.asc new file mode 100644 index 000000000..2ac6c181a --- /dev/null +++ b/crates/release-verify/testdata/genesis-manifest-v1.3.2.txt.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQQKbQXl3ZgGm6GE7YMEpo1iDVII/QUCal5wewAKCRAEpo1iDVII +/XbUAP9kh/faS8psab121tcahPtAxi6/DMsHKHY+JOQOo6wGJwEAtzw2wRiRw2+n +bvmGv1cSXmu7E3NKUBCWLaz/7erVwwM= +=p26N +-----END PGP SIGNATURE----- diff --git a/crates/release-verify/testdata/genesis-v0.3.4-dev.json.gz.asc b/crates/release-verify/testdata/genesis-v0.3.4-dev.json.gz.asc new file mode 100644 index 000000000..2ee27dbfb --- /dev/null +++ b/crates/release-verify/testdata/genesis-v0.3.4-dev.json.gz.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQQKbQXl3ZgGm6GE7YMEpo1iDVII/QUCaGvg7wAKCRAEpo1iDVII +/Ul0AQC/rLHCRRiZNz6uC/WQElD3Kk/RrGjq5KIUfH1jcGvP3AD+KG3qETWD3BhF +TcvhffzYq46+jcCMif1GzuHerOX/lwk= +=IaXH +-----END PGP SIGNATURE----- diff --git a/crates/release-verify/testdata/genesis-v0.5.7.json.gz.asc b/crates/release-verify/testdata/genesis-v0.5.7.json.gz.asc new file mode 100644 index 000000000..2bccb4858 --- /dev/null +++ b/crates/release-verify/testdata/genesis-v0.5.7.json.gz.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQQKbQXl3ZgGm6GE7YMEpo1iDVII/QUCaa8RmgAKCRAEpo1iDVII +/Sq1AP9LofwbA0cNDl5spgyTKBRS5Yv20ILUW7TJwjJbZNDlkQEA7A/o+dfnvEU+ +lCBH8knODJSp5fc8LPSHmDQFGzY9EA0= +=Vtoy +-----END PGP SIGNATURE----- diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 695730ca7..54c4e732c 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -48,6 +48,10 @@ impl Self(Evm { ctx, inspector, + // Pinned to match the delegated EVM runtime, which always executes at Osaka because + // it is versioned by contract upgrade rather than by hardfork (see the + // `fluentbase_evm::evm` module docs). Deriving this from the chain spec instead + // would make the two interpreters disagree on which opcodes exist. instruction: EthInstructions::new_mainnet_with_spec(SpecId::OSAKA), precompiles: RwasmPrecompiles::default(), frame_stack: FrameStack::new(), diff --git a/crates/revm/src/executor.rs b/crates/revm/src/executor.rs index ecaeb17b7..7e27ae133 100644 --- a/crates/revm/src/executor.rs +++ b/crates/revm/src/executor.rs @@ -232,6 +232,13 @@ fn execute_rwasm_frame>( } // Encode the shared context (block/tx/contract) that the runtime expects. + // + // The active hardfork is deliberately absent: delegated runtimes pin their own spec and are + // versioned by contract upgrade instead of fork activation (see the `fluentbase_evm::evm` + // module docs), so there is nothing here for them to follow. + // + // EIP-4844 blob fields are absent for a different reason: Fluent does not support blob + // transactions at all, so there is no value to carry (see `SharedContextInputV1::tx`). let context_input = SharedContextInput::V1(SharedContextInputV1 { block: BlockContextV1 { chain_id: ctx.cfg().chain_id(), diff --git a/crates/revm/src/syscall.rs b/crates/revm/src/syscall.rs index 6a6d7fdaa..ef57c23bc 100644 --- a/crates/revm/src/syscall.rs +++ b/crates/revm/src/syscall.rs @@ -1239,6 +1239,14 @@ pub(crate) fn execute_rwasm_interruption>( return_halt!(MemoryOutOfBounds); }; let rwasm_binary: Bytes = rwasm_binary.into(); + // No `RWASM_MAX_CODE_SIZE` check here, intentionally, and note the contrast with the + // EVM branch below which does enforce `EVM_MAX_CODE_SIZE`. The caller is pinned to + // `PRECOMPILE_RUNTIME_UPGRADE` by the assert above, and that contract only reaches this + // syscall after checking upgrade authority, so the size caps that bound untrusted + // deployment add nothing here. Bounding system runtimes by the deploy-time limit would + // make a genesis contract that outgrows it un-upgradeable without a fork — the exact + // situation this syscall exists to avoid. See `compile_and_install` in + // `contracts/runtime-upgrade` for the full argument (audit FLU-1075, closed as intended). #[cfg(feature = "std")] warn!( ?target_address, diff --git a/crates/runtime/src/executor.rs b/crates/runtime/src/executor.rs index 5bbe5c388..67727bf42 100644 --- a/crates/runtime/src/executor.rs +++ b/crates/runtime/src/executor.rs @@ -7,6 +7,7 @@ use crate::{ use fluentbase_types::{ byteorder::{ByteOrder, LittleEndian}, import_linker_v1_preview, Address, BytecodeOrHash, ExitCode, HashMap, B256, + MAX_IN_FLIGHT_MEMORY_BYTES, }; use rwasm::{ExecutionEngine, ImportLinker, RwasmModule, StrategyDefinition, TrapCode}; use std::{cell::RefCell, mem::take, sync::Arc}; @@ -187,6 +188,11 @@ pub struct RuntimeFactoryExecutor { pub import_linker: Arc, /// Monotonically increasing counter for assigning call identifiers. pub transaction_call_id_counter: u32, + /// Ceiling on linear memory held simultaneously by all live frames of one transaction. + /// + /// Defaults to [`MAX_IN_FLIGHT_MEMORY_BYTES`]; overridable so tests can exercise the limit + /// without allocating gigabytes. + pub max_in_flight_memory_bytes: u64, } impl RuntimeFactoryExecutor { @@ -196,9 +202,24 @@ impl RuntimeFactoryExecutor { recoverable_runtimes: HashMap::new(), import_linker, transaction_call_id_counter: 1, + max_in_flight_memory_bytes: MAX_IN_FLIGHT_MEMORY_BYTES, } } + /// Returns the linear memory held by every frame of this transaction that is currently + /// suspended awaiting resumption. + /// + /// This is derived from `recoverable_runtimes` on demand rather than tracked incrementally, + /// so it cannot drift out of sync with the frames that are actually alive. The map holds + /// every ancestor of the frame being created, which is precisely the set whose memory is + /// resident at the same time. + pub fn in_flight_memory_bytes(&self) -> u64 { + self.recoverable_runtimes + .values() + .map(|runtime| runtime.frame_memory_size_bytes() as u64) + .sum() + } + /// Saves the current runtime instance for later resumption and returns its call identifier. pub fn try_remember_runtime( &mut self, @@ -384,6 +405,31 @@ impl RuntimeExecutor for RuntimeFactoryExecutor { }; let mode = runtime_mode_label(&exec_mode); + // Bound the linear memory held simultaneously by every live frame of this transaction. + // + // Each suspended parent keeps its whole store alive in `recoverable_runtimes`, so a deep + // enough call chain pins `depth * frame_size` bytes of resident memory while paying only + // the per-frame fuel charge for it. Fuel prices a single allocation; it cannot bound the + // sum across frames, which is what exhausts the node. + // + // The check runs after construction rather than before: the page count a module declares + // is not a field on the module, it is encoded in the entrypoint bytecode, and decoding it + // would tie this to rWasm's codegen. Measuring the frame we just built avoids that + // entirely, and the resulting overshoot is bounded by one frame. + let in_flight = self.in_flight_memory_bytes() + exec_mode.frame_memory_size_bytes() as u64; + if in_flight > self.max_in_flight_memory_bytes { + // Dropping `exec_mode` here releases the frame that pushed us over the limit. + let result = ExecutionResult { + exit_code: ExitCode::OutOfMemory.into_i32(), + fuel_consumed: fuel_limit_value, + fuel_refunded: 0, + output: vec![], + return_data: vec![], + }; + metrics::record_execution(mode, state, &timer, &result); + return result; + } + // Execute program let result = exec_mode.execute(); let fuel_consumed = exec_mode @@ -501,12 +547,18 @@ fn runtime_labels(runtime: &ExecutionMode) -> (RuntimeModeLabel, &'static str) { #[cfg(test)] mod tests { use crate::{ - executor::{ExecutionInterruption, RuntimeFactoryExecutor, RuntimeResult}, - runtime::{ContractRuntime, ExecutionMode}, + executor::{ExecutionInterruption, RuntimeExecutor, RuntimeFactoryExecutor, RuntimeResult}, + runtime::{test_contract_module_with_memory, ContractRuntime, ExecutionMode}, RuntimeContext, }; - use fluentbase_types::{import_linker_v1_preview, ExitCode}; - use rwasm::{ExecutionEngine, RwasmModule, StrategyDefinition}; + use fluentbase_types::{ + import_linker_v1_preview, Address, BytecodeOrHash, ExitCode, B256, CALL_STACK_LIMIT, + MAX_IN_FLIGHT_MEMORY_BYTES, + }; + use rwasm::{ + ExecutionEngine, RwasmModule, StrategyDefinition, N_BYTES_PER_MEMORY_PAGE, + N_DEFAULT_MAX_MEMORY_PAGES, + }; #[test] fn call_id_overflow() { @@ -540,4 +592,245 @@ mod tests { assert_eq!(result.fuel_refunded, 0); assert!(result.output.is_empty()); } + + /// Initial pages the Rust/Wasm toolchain emits for a contract that allocates nothing of its + /// own; both `contracts/bn256` and `examples/greeting` compile down to exactly this. + const TYPICAL_CONTRACT_PAGES: u64 = 17; + + fn contract_bytecode_with_memory(pages: u32) -> BytecodeOrHash { + let module = test_contract_module_with_memory(pages); + BytecodeOrHash::Bytecode { + hash: B256::with_last_byte(pages as u8), + bytecode: module, + address: Address::ZERO, + } + } + + fn suspended_frame_with_memory(executor: &RuntimeFactoryExecutor, pages: u32) -> ExecutionMode { + suspended_frame(executor, test_contract_module_with_memory(pages)) + } + + fn suspended_frame(executor: &RuntimeFactoryExecutor, module: RwasmModule) -> ExecutionMode { + let runtime = ContractRuntime::new( + StrategyDefinition::Rwasm { + module, + engine: ExecutionEngine::acquire_shared(), + }, + executor.import_linker.clone(), + RuntimeContext::default(), + None, + ) + .expect("test frame must instantiate"); + ExecutionMode::Contract(runtime) + } + + #[test] + fn in_flight_memory_sums_every_suspended_frame() { + let mut executor = RuntimeFactoryExecutor::new(import_linker_v1_preview()); + assert_eq!(executor.in_flight_memory_bytes(), 0); + + // Frames stay resident while suspended, so the cost of a call chain is the sum over + // frames, not the size of the largest one. + for (call_id, pages) in [(1u32, 3u32), (2, 5)] { + let frame = suspended_frame_with_memory(&executor, pages); + executor.recoverable_runtimes.insert(call_id, frame); + } + + assert_eq!( + executor.in_flight_memory_bytes(), + (3 + 5) * N_BYTES_PER_MEMORY_PAGE as u64 + ); + } + + #[test] + fn in_flight_memory_ignores_frames_that_were_forgotten() { + let mut executor = RuntimeFactoryExecutor::new(import_linker_v1_preview()); + let frame = suspended_frame_with_memory(&executor, 4); + executor.recoverable_runtimes.insert(7, frame); + assert_eq!( + executor.in_flight_memory_bytes(), + 4 * N_BYTES_PER_MEMORY_PAGE as u64 + ); + + executor.forget_runtime(7); + assert_eq!(executor.in_flight_memory_bytes(), 0); + } + + #[test] + fn frame_exceeding_the_in_flight_cap_is_rejected() { + let mut executor = RuntimeFactoryExecutor::new(import_linker_v1_preview()); + // Room for the four pages already suspended, but not for the frame about to be built. + executor.max_in_flight_memory_bytes = 5 * N_BYTES_PER_MEMORY_PAGE as u64; + let frame = suspended_frame_with_memory(&executor, 4); + executor.recoverable_runtimes.insert(1, frame); + + let result = executor.execute( + contract_bytecode_with_memory(3), + RuntimeContext::default().with_fuel_limit(1_000_000), + ); + + assert_eq!(result.exit_code, ExitCode::OutOfMemory.into_i32()); + // The rejected frame must not stay resident. + assert_eq!( + executor.in_flight_memory_bytes(), + 4 * N_BYTES_PER_MEMORY_PAGE as u64 + ); + } + + #[test] + fn frame_within_the_in_flight_cap_executes() { + let mut executor = RuntimeFactoryExecutor::new(import_linker_v1_preview()); + executor.max_in_flight_memory_bytes = 8 * N_BYTES_PER_MEMORY_PAGE as u64; + let frame = suspended_frame_with_memory(&executor, 4); + executor.recoverable_runtimes.insert(1, frame); + + let result = executor.execute( + contract_bytecode_with_memory(3), + RuntimeContext::default().with_fuel_limit(1_000_000), + ); + + assert_ne!(result.exit_code, ExitCode::OutOfMemory.into_i32()); + } + + /// Drives a recursive call chain frame by frame, suspending each one the way a nested call + /// does, and reports how many frames were admitted before the cap refused one. + /// + /// The real attack is `depth * frame_size`, so the shape reproduces at any frame size: many + /// small frames stand in for the few huge ones that would need 64 GiB to run for real. Each + /// admitted frame is parked in `recoverable_runtimes`, which is exactly the state a suspended + /// parent leaves behind on the production path. + fn run_call_chain(executor: &mut RuntimeFactoryExecutor, frame_pages: u32) -> u32 { + let module = test_contract_module_with_memory(frame_pages); + let bytecode = BytecodeOrHash::Bytecode { + hash: B256::with_last_byte(frame_pages as u8), + bytecode: module.clone(), + address: Address::ZERO, + }; + + let mut admitted = 0u32; + for call_id in 1..=CALL_STACK_LIMIT { + // Generous enough to cover the initial-memory charge of even a maximum-size frame + // (1023 pages costs 1_047_552 fuel), so fuel never masks the memory cap. + let result = executor.execute( + bytecode.clone(), + RuntimeContext::default().with_fuel_limit(1_000_000_000), + ); + if result.exit_code == ExitCode::OutOfMemory.into_i32() { + break; + } + assert_eq!( + result.exit_code, 0, + "frame {call_id} failed for another reason" + ); + let frame = suspended_frame(executor, module.clone()); + executor.recoverable_runtimes.insert(call_id, frame); + admitted += 1; + } + admitted + } + + #[test] + fn full_depth_chain_of_ordinary_frames_is_admitted() { + let mut executor = RuntimeFactoryExecutor::new(import_linker_v1_preview()); + // Scaled to the same ratio the production cap has against ordinary contracts: enough + // headroom for every frame the call stack permits. + executor.max_in_flight_memory_bytes = + CALL_STACK_LIMIT as u64 * N_BYTES_PER_MEMORY_PAGE as u64; + + let admitted = run_call_chain(&mut executor, 1); + + // Depth on its own must never trip the cap — only total memory may. + assert_eq!(admitted, CALL_STACK_LIMIT); + } + + #[test] + fn deep_chain_of_memory_heavy_frames_is_cut_off_long_before_full_depth() { + let mut executor = RuntimeFactoryExecutor::new(import_linker_v1_preview()); + executor.max_in_flight_memory_bytes = + CALL_STACK_LIMIT as u64 * N_BYTES_PER_MEMORY_PAGE as u64; + + // Same budget, same depth limit, frames eight times fatter: the chain must die at an + // eighth of the depth rather than running to 1024 and pinning eight times the memory. + let admitted = run_call_chain(&mut executor, 8); + + assert_eq!(admitted, CALL_STACK_LIMIT / 8); + assert!( + executor.in_flight_memory_bytes() <= executor.max_in_flight_memory_bytes, + "peak memory must never exceed the cap", + ); + } + + /// The attack at full scale: a recursion that asks for `CALL_STACK_LIMIT` frames of the + /// largest memory a module may declare — about 64 GiB — must come away with no more than + /// [`MAX_IN_FLIGHT_MEMORY_BYTES`]. + /// + /// Unlike the scaled tests above, this one runs the production cap against production frame + /// sizes, so it really does allocate ~1.5 GiB of resident memory before the cap refuses the + /// next frame. That is the point — the cap is what stops it becoming 64 GiB — but it makes + /// the test too memory-hungry for a default `cargo test` run on a constrained machine. + /// + /// Run it explicitly with: + /// `cargo test -p fluentbase-runtime --lib recursion_demanding -- --ignored --nocapture` + #[test] + #[ignore = "allocates ~1.5 GiB of resident memory by design"] + fn recursion_demanding_64_gib_is_capped_at_the_in_flight_limit() { + let mut executor = RuntimeFactoryExecutor::new(import_linker_v1_preview()); + assert_eq!( + executor.max_in_flight_memory_bytes, MAX_IN_FLIGHT_MEMORY_BYTES, + "this test must exercise the production cap", + ); + + let largest_frame_pages = N_DEFAULT_MAX_MEMORY_PAGES - 1; + let frame_bytes = largest_frame_pages as u64 * N_BYTES_PER_MEMORY_PAGE as u64; + let demanded = CALL_STACK_LIMIT as u64 * frame_bytes; + assert!( + demanded > 60 * 1024 * 1024 * 1024, + "the chain should be demanding tens of GiB, got {demanded} bytes", + ); + + let admitted = run_call_chain(&mut executor, largest_frame_pages); + let held = executor.in_flight_memory_bytes(); + + // The chain dies at the cap, not at the call-stack limit. + assert_eq!(admitted, (MAX_IN_FLIGHT_MEMORY_BYTES / frame_bytes) as u32); + assert!(admitted < CALL_STACK_LIMIT); + assert!( + held <= MAX_IN_FLIGHT_MEMORY_BYTES, + "held {held} bytes, cap is {MAX_IN_FLIGHT_MEMORY_BYTES}", + ); + // What the attacker actually got is a small fraction of what was asked for. + assert!(held * 40 < demanded); + } + + #[test] + fn cap_admits_full_depth_recursion_of_ordinary_contracts() { + // Compatibility floor: nothing a normal contract can do today may start failing. The + // deepest legitimate chain is `CALL_STACK_LIMIT` frames of a default-sized contract. + let worst_legitimate = + CALL_STACK_LIMIT as u64 * TYPICAL_CONTRACT_PAGES * N_BYTES_PER_MEMORY_PAGE as u64; + + assert!( + MAX_IN_FLIGHT_MEMORY_BYTES > worst_legitimate, + "cap {MAX_IN_FLIGHT_MEMORY_BYTES} would break legitimate depth-{CALL_STACK_LIMIT} \ + recursion needing {worst_legitimate} bytes", + ); + } + + #[test] + fn cap_bounds_frames_holding_the_largest_permitted_memory() { + // Security ceiling: the same depth filled with maximum-memory frames must be cut off + // far below the ~64 GiB it would otherwise reach. + let largest_frame = + (N_DEFAULT_MAX_MEMORY_PAGES - 1) as u64 * N_BYTES_PER_MEMORY_PAGE as u64; + let affordable_frames = MAX_IN_FLIGHT_MEMORY_BYTES / largest_frame; + + assert!( + affordable_frames < 32, + "cap admits {affordable_frames} maximum-memory frames", + ); + assert!( + affordable_frames * largest_frame < 2 * 1024 * 1024 * 1024, + "peak memory must stay under 2 GiB", + ); + } } diff --git a/crates/runtime/src/runtime.rs b/crates/runtime/src/runtime.rs index 55888d35a..9abc8e9b4 100644 --- a/crates/runtime/src/runtime.rs +++ b/crates/runtime/src/runtime.rs @@ -12,6 +12,8 @@ use crate::RuntimeContext; use rwasm::TrapCode; mod contract_runtime; +#[cfg(test)] +pub(crate) use contract_runtime::test_contract_module_with_memory; pub use contract_runtime::ContractRuntime; mod system_runtime; @@ -87,6 +89,18 @@ impl ExecutionMode { } } + /// Returns the linear memory this frame holds on its own, in bytes. + /// + /// Only contract frames report memory here. A `System` runtime executes against a compiled + /// store that is cached and reused across calls rather than allocated per frame, so charging + /// its memory to every frame would count one allocation many times over. + pub fn frame_memory_size_bytes(&self) -> usize { + match self { + ExecutionMode::Contract(runtime) => runtime.memory_size_bytes(), + ExecutionMode::System(_) => 0, + } + } + /// Returns the remaining execution fuel, if fuel metering is enabled. /// /// Some runtimes may choose not to expose fuel accounting; in that case diff --git a/crates/runtime/src/runtime/contract_runtime.rs b/crates/runtime/src/runtime/contract_runtime.rs index abbbc8348..5238d5b81 100644 --- a/crates/runtime/src/runtime/contract_runtime.rs +++ b/crates/runtime/src/runtime/contract_runtime.rs @@ -18,6 +18,24 @@ use rwasm::{ }; use std::sync::Arc; +#[cfg(test)] +pub(crate) fn test_contract_module_with_memory(initial_pages: u32) -> rwasm::RwasmModule { + let wasm = wat::parse_str(format!( + r#" + (module + (memory (export "memory") {initial_pages}) + (func (export "main")) + (func (export "deploy")) + ) + "# + )) + .expect("test WAT must be valid"); + let config = rwasm::CompilationConfig::default().with_entrypoint_name("main".into()); + let (module, _) = rwasm::RwasmModule::compile(config, &wasm) + .expect("rWasm compiler must accept the test contract module"); + module +} + /// Runtime responsible for executing a single contract invocation. /// /// This runtime encapsulates a concrete execution `Strategy` @@ -110,6 +128,19 @@ impl ContractRuntime { self.executor.memory_read(offset, buffer) } + /// Returns the linear memory currently allocated to this frame, in bytes. + /// + /// The store owns this memory for as long as the frame is alive — including while the frame + /// sits suspended waiting to be resumed — so this is the quantity a caller must sum to bound + /// the memory held simultaneously across a call chain. + pub fn memory_size_bytes(&self) -> usize { + match &self.executor { + StrategyExecutor::Rwasm { store, .. } => store.memory_size_bytes(), + #[allow(unreachable_patterns)] + _ => 0, + } + } + /// Returns the remaining execution fuel if fuel metering is enabled. /// /// Returns `None` if fuel accounting is disabled for this execution. @@ -132,3 +163,99 @@ impl ContractRuntime { self.executor.data() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::test_contract_module_with_memory; + use fluentbase_types::import_linker_v1_preview; + use rwasm::{ + ExecutionEngine, FuelCosts, RwasmModule, N_BYTES_PER_MEMORY_PAGE, + N_DEFAULT_MAX_MEMORY_PAGES, + }; + + fn initial_memory_fuel(initial_memory_pages: u32) -> Result { + let initial_memory_bytes = initial_memory_pages + .checked_mul(N_BYTES_PER_MEMORY_PAGE) + .ok_or(TrapCode::MemoryOutOfBounds)?; + Ok(u64::from(FuelCosts::fuel_for_bytes(initial_memory_bytes))) + } + + fn strategy(module: RwasmModule) -> StrategyDefinition { + StrategyDefinition::Rwasm { + engine: ExecutionEngine::acquire_shared(), + module, + } + } + + #[test] + fn rwasm_initializer_charges_initial_memory_fuel() { + let initial_pages = N_DEFAULT_MAX_MEMORY_PAGES - 1; + let module = test_contract_module_with_memory(initial_pages); + let fuel_limit = 1_000_000_000u64; + + // This invokes rWasm directly rather than ContractRuntime so the test isolates the + // compiler-generated initializer and its proportional bulk-operation fuel charge. + let executor = strategy(module) + .create_executor( + import_linker_v1_preview(), + RuntimeContext::default(), + runtime_syscall_handler, + Some(fuel_limit), + Some(N_DEFAULT_MAX_MEMORY_PAGES), + ) + .expect("sufficient fuel must cover the initial-memory charge"); + + // The initializer is a synthesized bytecode segment, so it carries none of the per-block + // ConsumeFuel that the translator injects into regular functions. The only fuel it burns + // is the bulk-op charge that `op_memory_grow_checked` emits ahead of MemoryGrow (enabled + // by `CompilationConfig::default().consume_fuel_for_bulk_ops`), which is exactly + // `initial_memory_fuel`: + // + // initial_pages * N_BYTES_PER_MEMORY_PAGE / MEMORY_BYTES_PER_FUEL + // = 1023 * 65536 / 64 = 1_047_552 + // + // so the executor is left with 1_000_000_000 - 1_047_552 = 998_952_448. + let memory_fuel = initial_memory_fuel(initial_pages).unwrap(); + assert_eq!(executor.remaining_fuel(), Some(fuel_limit - memory_fuel)); + #[allow(irrefutable_let_patterns)] + let StrategyExecutor::Rwasm { store, .. } = executor + else { + unreachable!() + }; + assert_eq!( + store.memory_size_bytes(), + initial_pages as usize * N_BYTES_PER_MEMORY_PAGE as usize + ); + } + + #[test] + fn meters_initial_memory_before_instantiation() { + let initial_pages = 1; + let memory_fuel = initial_memory_fuel(initial_pages).unwrap(); + let runtime = ContractRuntime::new( + strategy(test_contract_module_with_memory(initial_pages)), + import_linker_v1_preview(), + RuntimeContext::default(), + Some(memory_fuel + 10), + ) + .unwrap(); + assert_eq!(runtime.remaining_fuel(), Some(10)); + } + + #[test] + fn rejects_maximum_initial_memory_before_allocation_when_underfunded() { + let initial_pages = N_DEFAULT_MAX_MEMORY_PAGES - 1; + let memory_fuel = initial_memory_fuel(initial_pages).unwrap(); + let error = ContractRuntime::new( + strategy(test_contract_module_with_memory(initial_pages)), + import_linker_v1_preview(), + RuntimeContext::default(), + Some(memory_fuel - 1), + ) + .err() + .expect("underfunded initial memory must fail before instantiation"); + + assert_eq!(error, TrapCode::OutOfFuel); + } +} diff --git a/crates/runtime/src/syscall_handler/edwards/edwards_add.rs b/crates/runtime/src/syscall_handler/edwards/edwards_add.rs index 39c5a3970..51a03e7c8 100644 --- a/crates/runtime/src/syscall_handler/edwards/edwards_add.rs +++ b/crates/runtime/src/syscall_handler/edwards/edwards_add.rs @@ -16,7 +16,7 @@ pub fn syscall_edwards_add_handler( ctx.memory_read(q_ptr as usize, &mut q_bytes)?; let res = syscall_edwards_add_impl(p_bytes, q_bytes) .map_err(|e| syscall_process_exit_code(ctx, e))?; - ctx.memory_write(q_ptr as usize, &res)?; + ctx.memory_write(p_ptr as usize, &res)?; Ok(()) } diff --git a/crates/sdk-derive/derive-core/Cargo.toml b/crates/sdk-derive/derive-core/Cargo.toml index b51e970df..f8d5fd634 100644 --- a/crates/sdk-derive/derive-core/Cargo.toml +++ b/crates/sdk-derive/derive-core/Cargo.toml @@ -32,6 +32,7 @@ tracing = { version = "0.1.40", default-features = false } bytes = "1.0" insta = { version = "1.43.1", features = ["yaml"] } prettyplease = "0.2.32" +tempfile = { workspace = true } [features] default = [] diff --git a/crates/sdk-derive/derive-core/src/abi/constructor.rs b/crates/sdk-derive/derive-core/src/abi/constructor.rs index cf6f0f5e7..25e15af6a 100644 --- a/crates/sdk-derive/derive-core/src/abi/constructor.rs +++ b/crates/sdk-derive/derive-core/src/abi/constructor.rs @@ -1,5 +1,5 @@ use super::types::rust_to_sol; -use crate::abi::{error::ABIError, parameter::Parameter}; +use crate::abi::{error::ABIError, parameter::Parameter, structs::StructResolver}; use serde::{Deserialize, Serialize}; use syn::{FnArg, Pat, Signature}; @@ -29,6 +29,31 @@ impl ConstructorABI { }) } + /// Builds the ABI with every struct parameter expanded into its components + pub fn from_signature_with( + sig: &Signature, + resolver: &StructResolver, + ) -> Result { + let mut abi = Self::from_signature(sig)?; + abi.resolve_structs(resolver)?; + Ok(abi) + } + + /// Expands the components of every struct parameter, if any parameter needs it + pub fn resolve_structs(&mut self, resolver: &StructResolver) -> Result<(), ABIError> { + if !self.inputs.iter().any(Parameter::has_unresolved_struct) { + return Ok(()); + } + + let structs = resolver.structs()?; + for parameter in &mut self.inputs { + // Contract signatures live in the crate root, so they resolve from there + parameter.resolve_structs(structs, "")?; + } + + Ok(()) + } + fn convert_inputs(inputs: &[&FnArg]) -> Result, ABIError> { inputs .iter() diff --git a/crates/sdk-derive/derive-core/src/abi/error.rs b/crates/sdk-derive/derive-core/src/abi/error.rs index fc141bcb1..8c4129970 100644 --- a/crates/sdk-derive/derive-core/src/abi/error.rs +++ b/crates/sdk-derive/derive-core/src/abi/error.rs @@ -18,6 +18,9 @@ pub enum ABIError { #[error("Unsupported pattern: {0}")] UnsupportedPattern(String), + #[error("Struct resolution error: {0}")] + StructResolution(String), + #[error("Syntax error: {0}")] Syntax(String), diff --git a/crates/sdk-derive/derive-core/src/abi/function.rs b/crates/sdk-derive/derive-core/src/abi/function.rs index f169e2e5b..dcb991777 100644 --- a/crates/sdk-derive/derive-core/src/abi/function.rs +++ b/crates/sdk-derive/derive-core/src/abi/function.rs @@ -1,5 +1,5 @@ use super::types::{rust_to_sol, ConversionError}; -use crate::abi::{error::ABIError, parameter::Parameter}; +use crate::abi::{error::ABIError, parameter::Parameter, structs::StructResolver}; use convert_case::{Case, Casing}; use crypto_hashes::{digest::Digest, sha3::Keccak256}; use serde::{Deserialize, Serialize}; @@ -28,7 +28,7 @@ pub struct FunctionABI { } /// Represents state mutability in Solidity -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum StateMutability { /// Can't read state @@ -58,6 +58,39 @@ impl FunctionABI { }) } + /// Builds the ABI with every struct parameter expanded into its components + /// + /// This is the representation both the router selector and the published artifacts are derived + /// from; see [`crate::abi::structs`]. + pub fn from_signature_with( + sig: &Signature, + resolver: &StructResolver, + ) -> Result { + let mut abi = Self::from_signature(sig)?; + abi.resolve_structs(resolver)?; + Ok(abi) + } + + /// Expands the components of every struct parameter, if any parameter needs it + pub fn resolve_structs(&mut self, resolver: &StructResolver) -> Result<(), ABIError> { + if !self + .inputs + .iter() + .chain(self.outputs.iter()) + .any(Parameter::has_unresolved_struct) + { + return Ok(()); + } + + let structs = resolver.structs()?; + for parameter in self.inputs.iter_mut().chain(self.outputs.iter_mut()) { + // Contract signatures live in the crate root, so they resolve from there + parameter.resolve_structs(structs, "")?; + } + + Ok(()) + } + fn convert_inputs(inputs: &[&FnArg]) -> Result, ABIError> { inputs .iter() diff --git a/crates/sdk-derive/derive-core/src/abi/mod.rs b/crates/sdk-derive/derive-core/src/abi/mod.rs index 310ebe7b2..3b448ab0f 100644 --- a/crates/sdk-derive/derive-core/src/abi/mod.rs +++ b/crates/sdk-derive/derive-core/src/abi/mod.rs @@ -5,57 +5,46 @@ //! //! * `SolType` - represents Solidity types, parses Rust types into their Solidity equivalents //! * `FunctionABI` - represents Solidity function definitions -//! * `RustToSol` - converts Rust types to their Solidity equivalents using registry +//! * `StructRegistry` - the crate's `#[derive(Codec)]` structs, used to expand struct parameters //! //! # Function ID Generation //! -//! Module can generate function signatures and IDs from Rust code in two modes: -//! -//! 1. Registry mode (enabled via feature flag): -//! - All structure definitions are loaded into registry at startup -//! - When constructing `FunctionABI`, registry is used to resolve types -//! - Provides complete type information for complex structures -//! -//! 2. Direct mode (default): -//! - No registry preloading -//! - Types are converted directly during `FunctionABI` construction -//! - Suitable for simple cases without complex nested structures +//! A Rust type is converted to its Solidity equivalent by `rust_to_sol`, which only sees the type +//! path: any unknown type becomes a struct with no components. Such a parameter has no canonical +//! signature, so a `FunctionABI` must be resolved through a [`structs::StructResolver`] before its +//! signature or function ID is taken. The router macro and the build tooling both do this, which is +//! what keeps the compiled dispatch table and the published artifacts on the same selector. //! //! ```rust, ignore -//! use crate::abi::{FunctionABI, RustToSol}; -//! -//! // Parse Rust function signature +//! // Parse a Rust function signature //! let sig: syn::Signature = parse_quote! { -//! fn transfer(amount: u64, recipient: String) -> String +//! fn transfer(params: TransferParams) -> bool //! }; //! -//! // Create registry (optional) -//! // check artifacts for structure definitions -//! -//! let config = ArtifactsRegistryConfig::new("OUT_DIR").with_mirror(".artifacts"); -//! let registry = ArtifactsRegistry::new(config)?; -//! +//! // Structs of the crate being compiled, parsed on demand +//! let resolver = StructResolver::crate_sources(); //! -//! // Convert to FunctionABI -//! let abi = FunctionABI::from_syn(&sig, registry)?; +//! // Convert to FunctionABI, expanding `TransferParams` into its components +//! let abi = FunctionABI::from_signature_with(&sig, &resolver)?; //! -//! // Get function ID (first 4 bytes of keccak256 hash) -//! let function_id = abi.function_id(); +//! // Canonical function signature, e.g. "transfer((address,uint256))" +//! let signature = abi.signature()?; //! -//! // Get canonical function signature (e.g. "transfer(uint256,string)") -//! let signature = abi.signature(); +//! // Function ID (first 4 bytes of the keccak256 hash of that signature) +//! let function_id = abi.function_id()?; //! ``` //! //! # Constraints //! -//! - Types must implement `SolidityABI` derive macro +//! - Struct parameters must be declared with `#[derive(Codec)]` in the contract's own crate, or +//! have their selector pinned with `#[function_id("...")]` //! - Generic types are not supported -//! - Module path used for type identification in registry -//! - Only non-generic Rust types can be converted to Solidity types -//! - Complex types must be registered in the registry when using Registry mode +//! - Module path is used for type identification, so a bare name matching several modules is +//! rejected rather than resolved arbitrarily pub mod constructor; pub mod contract; pub mod error; pub mod function; pub mod parameter; +pub mod structs; pub mod types; diff --git a/crates/sdk-derive/derive-core/src/abi/parameter.rs b/crates/sdk-derive/derive-core/src/abi/parameter.rs index 8492eaca8..fc49434ca 100644 --- a/crates/sdk-derive/derive-core/src/abi/parameter.rs +++ b/crates/sdk-derive/derive-core/src/abi/parameter.rs @@ -70,14 +70,10 @@ impl Parameter { internal_type: format!("struct {struct_name}"), ty: "tuple".to_string(), name, - components: Some( - fields - .iter() - .map(|(field_name, field_type)| { - Self::from_sol_type(field_type.clone(), field_name.clone()) - }) - .collect(), - ), + // A struct type carries no fields at this point unless the caller already knows + // them; `None` marks it as still to be resolved, which is what keeps an + // unresolved struct from silently hashing as an empty tuple + components: struct_components(fields), }, SolType::Tuple(types) => Self { internal_type: "tuple".to_string(), @@ -108,17 +104,8 @@ impl Parameter { // For arrays of structs, we need to provide components let components = match &**inner { - SolType::Struct { fields, .. } => { - // Create components from struct fields - Some( - fields - .iter() - .map(|(field_name, field_type)| { - Self::from_sol_type(field_type.clone(), field_name.clone()) - }) - .collect(), - ) - } + // Create components from struct fields + SolType::Struct { fields, .. } => struct_components(fields), SolType::Tuple(types) => { // For tuple arrays, provide tuple components Some( @@ -153,14 +140,7 @@ impl Parameter { }; let components = match &**inner { - SolType::Struct { fields, .. } => Some( - fields - .iter() - .map(|(field_name, field_type)| { - Self::from_sol_type(field_type.clone(), field_name.clone()) - }) - .collect(), - ), + SolType::Struct { fields, .. } => struct_components(fields), SolType::Tuple(types) => Some( types .iter() @@ -187,24 +167,41 @@ impl Parameter { } } + /// Canonical Solidity type of this parameter, as it appears in a function signature + /// + /// Tuples - including struct and tuple arrays - expand to their components, because the + /// selector is hashed from this string and callers expand structs the same way. A struct whose + /// components have not been resolved has no canonical form: emitting `()` for it would fix the + /// router selector on a signature no caller can reproduce, so it is an error instead. See + /// [`crate::abi::structs`] for how components are resolved before this point. pub fn get_canonical_type(&self) -> Result { - if self.ty == "tuple" { - let components = self.components.as_ref().ok_or_else(|| { - ConversionError::UnsupportedType("Tuple without components".to_string()) - })?; - - let inner_types = components - .iter() - .map(Parameter::get_canonical_type) - .collect::, _>>()?; - - Ok(format!("({})", inner_types.join(","))) - } else if self.ty.ends_with("[]") { - let base_type = &self.ty[..self.ty.len() - 2]; - Ok(format!("{base_type}[]")) - } else { - Ok(self.ty.clone()) + let (base_type, array_suffix) = split_array_suffix(&self.ty); + + if base_type != "tuple" { + return Ok(self.ty.clone()); } + + let components = self.components.as_ref().ok_or_else(|| { + if self.is_struct() { + ConversionError::UnsupportedType(format!( + "components of `{}` are unresolved, so the canonical type of parameter `{}` \ + cannot be computed", + self.internal_type + .strip_prefix("struct ") + .unwrap_or(&self.internal_type), + self.name + )) + } else { + ConversionError::UnsupportedType("Tuple without components".to_string()) + } + })?; + + let inner_types = components + .iter() + .map(Parameter::get_canonical_type) + .collect::, _>>()?; + + Ok(format!("({}){array_suffix}", inner_types.join(","))) } #[must_use] @@ -213,6 +210,35 @@ impl Parameter { } } +/// Components of a struct type, or `None` while its fields are still unknown +/// +/// A struct that reaches [`Parameter`] without fields has not been resolved against the crate's +/// `#[derive(Codec)]` definitions yet; see [`crate::abi::structs`]. +fn struct_components(fields: &[(String, SolType)]) -> Option> { + if fields.is_empty() { + return None; + } + + Some( + fields + .iter() + .map(|(field_name, field_type)| { + Parameter::from_sol_type(field_type.clone(), field_name.clone()) + }) + .collect(), + ) +} + +/// Split an ABI type into its base type and the array suffixes attached to it +/// +/// `tuple[3][]` -> `("tuple", "[3][]")`, `uint256` -> `("uint256", "")` +fn split_array_suffix(ty: &str) -> (&str, &str) { + match ty.find('[') { + Some(index) => ty.split_at(index), + None => (ty, ""), + } +} + #[allow(dead_code)] /// Helper function to get full path from `TypePath` fn get_full_path(type_path: &TypePath) -> Result { diff --git a/crates/sdk-derive/derive-core/src/abi/structs.rs b/crates/sdk-derive/derive-core/src/abi/structs.rs new file mode 100644 index 000000000..a90bd881b --- /dev/null +++ b/crates/sdk-derive/derive-core/src/abi/structs.rs @@ -0,0 +1,1050 @@ +//! Resolution of `#[derive(Codec)]` structs used as contract parameters +//! +//! A Rust struct in a contract signature carries no field information at the point where a +//! parameter is converted to its Solidity type: `rust_to_sol` only sees the type path. Leaving it +//! that way makes the router selector fall out of an empty tuple while artifact generation later +//! publishes the real components, so callers hash a different signature than the deployed router +//! dispatches on. Both sides therefore resolve components through this module before any selector +//! is calculated. +//! +//! Discovery walks the crate's module tree starting from `lib.rs`/`main.rs` and follows `mod` +//! declarations in source order, so the registry never depends on filesystem enumeration order. +//! Structs are keyed by their module-qualified path (`types::Config`) rather than the bare +//! identifier, so two modules declaring the same struct name no longer overwrite each other. + +use crate::abi::{error::ABIError, parameter::Parameter}; +use quote::ToTokens; +use std::{ + cell::OnceCell, + collections::{BTreeMap, HashSet}, + env, fmt, + path::{Path, PathBuf}, +}; +use syn::{ + parse::Parser, parse_file, punctuated::Punctuated, Attribute, DeriveInput, Item, ItemMod, + ItemStruct, Meta, Path as SynPath, Token, +}; + +/// Crate root candidates, in the order cargo itself would pick them +const CRATE_ROOT_CANDIDATES: [&str; 4] = ["src/lib.rs", "src/main.rs", "lib.rs", "main.rs"]; + +/// Registry of `#[derive(Codec)]` structs found in a contract crate. +/// +/// Keys are module-qualified paths relative to the crate root: `Config` for a struct in the root +/// module, `types::Config` for one declared inside `mod types`. +#[derive(Default, Clone)] +pub struct StructRegistry { + // A `BTreeMap` keeps iteration - and therefore lookups and diagnostics - deterministic. + structs: BTreeMap>, +} + +// `syn::DeriveInput` only implements `Debug` under the `extra-traits` feature, so the registry +// reports the paths it holds instead of the definitions themselves. +impl fmt::Debug for StructRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StructRegistry") + .field("structs", &self.structs.keys().collect::>()) + .finish() + } +} + +impl StructRegistry { + /// Parse every `#[derive(Codec)]` struct reachable from a crate entry file + /// + /// # Arguments + /// * `entry_file` - Path to the crate root (`src/lib.rs` or `src/main.rs`) + pub fn parse_crate(entry_file: &Path) -> Result { + let mod_dir = entry_file + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + + let mut walker = CrateWalker::default(); + walker.walk_file(entry_file, &mod_dir, &mut Vec::new())?; + Ok(walker.registry) + } + + /// Parse the crate rooted at a package directory, locating its entry file + pub fn parse_package(package_dir: &Path) -> Result { + let entry_file = CRATE_ROOT_CANDIDATES + .iter() + .map(|candidate| package_dir.join(candidate)) + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + ABIError::StructResolution(format!( + "no crate root (lib.rs or main.rs) found in {}", + package_dir.display() + )) + })?; + + Self::parse_crate(&entry_file) + } + + /// Resolve a type path exactly as it was written in a contract signature + /// + /// # Arguments + /// * `path` - Type path as written, e.g. `Config`, `types::Config` or `crate::types::Config` + /// * `scope` - Module the path was written in (`""` for the crate root) + /// + /// # Returns + /// * `Ok(Some((path, def)))` - The matched module-qualified path and its definition + /// * `Ok(None)` - No `Codec` struct of this crate matches + /// * `Err(_)` - The name is ambiguous, so picking a definition would be arbitrary + pub fn resolve( + &self, + path: &str, + scope: &str, + ) -> Result, ABIError> { + let segments = split_path(path); + if segments.is_empty() { + return Ok(None); + } + + // Innermost scope first, mirroring Rust name resolution closely enough for the module + // layouts a contract can express. + let scope_segments = split_path(scope); + for depth in (0..=scope_segments.len()).rev() { + let mut candidate = scope_segments[..depth].to_vec(); + candidate.extend_from_slice(&segments); + if let Some((key, definitions)) = self.structs.get_key_value(&candidate.join("::")) { + return single_definition(key, definitions).map(Some); + } + } + + // Fall back to a unique suffix match anywhere in the crate, which is what lets a bare + // `Config` in a signature find `types::Config` while a duplicated name is rejected. + let matches = self + .structs + .keys() + .filter(|key| ends_with_segments(key, &segments)) + .collect::>(); + + match matches.as_slice() { + [] => Ok(None), + [only] => single_definition(only, &self.structs[*only]).map(Some), + ambiguous => Err(ABIError::StructResolution(format!( + "type `{path}` is ambiguous: it matches {}. \ + Use the module-qualified path in the contract signature.", + quoted_list(ambiguous.iter().map(|key| key.as_str())) + ))), + } + } + + fn insert(&mut self, module: &[String], item: &ItemStruct) { + let key = qualify(module, &item.ident.to_string()); + let derive_input = item_struct_to_derive_input(item); + let encoded = derive_input.to_token_stream().to_string(); + + let definitions = self.structs.entry(key).or_default(); + // `cfg`-gated definitions can legitimately repeat a name within one module; only + // textually different ones are a genuine conflict. + if definitions + .iter() + .any(|existing| existing.to_token_stream().to_string() == encoded) + { + return; + } + definitions.push(derive_input); + } +} + +/// Where struct definitions come from when a parameter has to be resolved. +/// +/// Resolution is lazy: a contract whose methods only take primitive types never reaches the +/// filesystem, so the cost is paid exactly by the contracts that need it. +pub enum StructResolver { + /// Structs of the crate currently being compiled, parsed on demand from `CARGO_MANIFEST_DIR`. + /// + /// This is what the `#[router]`, `#[constructor]` and `#[client]` macros use: the selector + /// they bake into the dispatch table has to be computed from the same components the build + /// tooling later publishes. + CrateSources(OnceCell>), + /// A registry the caller has already parsed, used by the build tooling. + Registry(StructRegistry), +} + +impl Default for StructResolver { + fn default() -> Self { + Self::crate_sources() + } +} + +impl StructResolver { + /// Resolver that parses the crate being compiled when a struct is first encountered + #[must_use] + pub fn crate_sources() -> Self { + Self::CrateSources(OnceCell::new()) + } + + /// Resolver backed by an already parsed registry + #[must_use] + pub fn registry(registry: StructRegistry) -> Self { + Self::Registry(registry) + } + + /// Returns the struct definitions, parsing the crate sources on first use + pub fn structs(&self) -> Result<&StructRegistry, ABIError> { + match self { + Self::Registry(registry) => Ok(registry), + Self::CrateSources(cell) => cell + .get_or_init(|| { + let package_dir = env::var("CARGO_MANIFEST_DIR").map_err(|_| { + "CARGO_MANIFEST_DIR is not set, so the crate sources cannot be located" + .to_string() + })?; + StructRegistry::parse_package(Path::new(&package_dir)) + .map_err(|error| error.to_string()) + }) + .as_ref() + .map_err(|error| { + ABIError::StructResolution(format!( + "cannot resolve struct parameters: {error}. Annotate the method with \ + #[function_id(\"...\")] to pin its selector explicitly." + )) + }), + } + } +} + +impl Parameter { + /// Path of the struct this parameter holds, with any array suffixes removed + #[must_use] + pub fn struct_path(&self) -> Option<&str> { + self.internal_type + .strip_prefix("struct ") + .map(|name| strip_array_suffixes(name).trim()) + } + + /// Whether this parameter (or any component of it) is a struct whose fields are still unknown + #[must_use] + pub fn has_unresolved_struct(&self) -> bool { + let unresolved = self.struct_path().is_some() && self.components.is_none(); + + unresolved + || self + .components + .iter() + .flatten() + .any(Parameter::has_unresolved_struct) + } + + /// Fill in the components of every struct this parameter is built from + /// + /// `scope` is the module whose namespace the parameter's type was written in: the crate root + /// for contract signatures, and the struct's own module for the components of an expanded + /// struct. + pub fn resolve_structs( + &mut self, + structs: &StructRegistry, + scope: &str, + ) -> Result<(), ABIError> { + self.resolve_structs_inner(structs, scope, &mut Vec::new()) + } + + fn resolve_structs_inner( + &mut self, + structs: &StructRegistry, + scope: &str, + expanding: &mut Vec, + ) -> Result<(), ABIError> { + // Components of an expanded struct are written in that struct's module, not in `scope` + let mut nested_scope = scope.to_string(); + let mut expanded_path = None; + + if let Some(name) = self.struct_path().map(str::to_string) { + let (path, definition) = structs.resolve(&name, scope)?.ok_or_else(|| { + ABIError::StructResolution(format!( + "struct `{name}` appears in the contract ABI but has no `#[derive(Codec)]` \ + definition in this crate; the generated ABI would have no components for it" + )) + })?; + + // A struct that contains itself has no finite ABI signature, and expanding it would + // otherwise recurse until the stack runs out. + if expanding.iter().any(|open| open == path) { + return Err(ABIError::StructResolution(format!( + "struct `{path}` is recursive, so it has no Solidity ABI representation" + ))); + } + + nested_scope = module_of(path).to_string(); + let path = path.to_string(); + let expanded = Parameter::from_derive_input(definition)?; + self.components = expanded.components; + expanding.push(path.clone()); + expanded_path = Some(path); + } + + for component in self.components.iter_mut().flatten() { + component.resolve_structs_inner(structs, &nested_scope, expanding)?; + } + + if expanded_path.is_some() { + expanding.pop(); + } + + Ok(()) + } +} + +/// Walks a crate's module tree, collecting structs with `#[derive(Codec)]` +#[derive(Default)] +struct CrateWalker { + registry: StructRegistry, + visited: HashSet, +} + +impl CrateWalker { + /// Parse one source file and descend into the modules it declares + /// + /// `mod_dir` is the directory holding the child modules of this file. + fn walk_file( + &mut self, + file: &Path, + mod_dir: &Path, + module: &mut Vec, + ) -> Result<(), ABIError> { + // `#[path]` attributes make it possible to reach the same file twice + let marker = file.canonicalize().unwrap_or_else(|_| file.to_path_buf()); + if !self.visited.insert(marker) { + return Ok(()); + } + + let content = std::fs::read_to_string(file).map_err(|error| { + ABIError::StructResolution(format!("failed to read {}: {error}", file.display())) + })?; + let ast = parse_file(&content).map_err(|error| { + ABIError::StructResolution(format!( + "failed to parse Rust file {}: {error}", + file.display() + )) + })?; + + self.walk_items(&ast.items, mod_dir, module) + } + + fn walk_items( + &mut self, + items: &[Item], + mod_dir: &Path, + module: &mut Vec, + ) -> Result<(), ABIError> { + for item in items { + match item { + Item::Struct(item_struct) if has_codec_derive(&item_struct.attrs) => { + self.registry.insert(module, item_struct); + } + Item::Mod(item_mod) => self.walk_module(item_mod, mod_dir, module)?, + _ => {} + } + } + Ok(()) + } + + fn walk_module( + &mut self, + item_mod: &ItemMod, + mod_dir: &Path, + module: &mut Vec, + ) -> Result<(), ABIError> { + let name = item_mod.ident.to_string(); + + // Inline module: its own children live one directory deeper + if let Some((_, items)) = &item_mod.content { + let child_dir = mod_dir.join(&name); + module.push(name); + let result = self.walk_items(items, &child_dir, module); + module.pop(); + return result; + } + + let Some(file) = module_file(item_mod, mod_dir, &name) else { + // Modules we cannot locate (generated sources, platform-gated files) simply + // contribute no structs; rustc reports the missing file if it actually matters. + eprintln!( + "Warning: could not locate source file for module `{name}` in {}", + mod_dir.display() + ); + return Ok(()); + }; + + let child_dir = child_mod_dir(&file); + module.push(name); + let result = self.walk_file(&file, &child_dir, module); + module.pop(); + result + } +} + +/// Locate the file backing `mod name;`, honoring `#[path = "..."]` +fn module_file(item_mod: &ItemMod, mod_dir: &Path, name: &str) -> Option { + if let Some(path) = path_attribute(&item_mod.attrs) { + let candidate = mod_dir.join(path); + return candidate.is_file().then_some(candidate); + } + + [ + mod_dir.join(format!("{name}.rs")), + mod_dir.join(name).join("mod.rs"), + ] + .into_iter() + .find(|candidate| candidate.is_file()) +} + +/// Extract the value of a `#[path = "..."]` attribute +fn path_attribute(attrs: &[Attribute]) -> Option { + attrs.iter().find_map(|attr| match &attr.meta { + Meta::NameValue(name_value) if name_value.path.is_ident("path") => { + match &name_value.value { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(literal), + .. + }) => Some(literal.value()), + _ => None, + } + } + _ => None, + }) +} + +/// Directory holding the child modules of a file +/// +/// `src/a.rs` -> `src/a`, `src/a/mod.rs` -> `src/a` +fn child_mod_dir(file: &Path) -> PathBuf { + let parent = file.parent().unwrap_or_else(|| Path::new(".")); + match file.file_stem().and_then(|stem| stem.to_str()) { + Some("mod") | None => parent.to_path_buf(), + Some(stem) => parent.join(stem), + } +} + +/// Returns true if attributes contain `#[derive(Codec)]` +fn has_codec_derive(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| match &attr.meta { + Meta::List(list) if list.path.is_ident("derive") => { + let derives = Punctuated::::parse_terminated + .parse2(list.tokens.clone()) + .ok(); + + derives + .map(|d| d.iter().any(|p| p.is_ident("Codec"))) + .unwrap_or(false) + } + _ => false, + }) +} + +/// Convert `ItemStruct` to `DeriveInput` +fn item_struct_to_derive_input(item: &ItemStruct) -> DeriveInput { + DeriveInput { + attrs: item.attrs.clone(), + vis: item.vis.clone(), + ident: item.ident.clone(), + generics: item.generics.clone(), + data: syn::Data::Struct(syn::DataStruct { + struct_token: item.struct_token, + fields: item.fields.clone(), + semi_token: item.semi_token, + }), + } +} + +/// Split a type path into segments, dropping the path qualifiers a signature may carry +fn split_path(path: &str) -> Vec<&str> { + path.split("::") + .map(str::trim) + .filter(|segment| !segment.is_empty() && !matches!(*segment, "crate" | "self" | "super")) + .collect() +} + +/// Whether a registry key ends with the requested path segments +fn ends_with_segments(key: &str, segments: &[&str]) -> bool { + let key_segments = key.split("::").collect::>(); + key_segments.len() >= segments.len() + && key_segments[key_segments.len() - segments.len()..] == *segments +} + +/// Build a module-qualified path for a struct declared in `module` +fn qualify(module: &[String], name: &str) -> String { + if module.is_empty() { + name.to_string() + } else { + format!("{}::{name}", module.join("::")) + } +} + +/// Module part of a qualified path (`types::Config` -> `types`, `Config` -> `""`) +fn module_of(path: &str) -> &str { + path.rfind("::").map_or("", |index| &path[..index]) +} + +fn quoted_list<'a>(items: impl Iterator) -> String { + items + .map(|item| format!("`{item}`")) + .collect::>() + .join(", ") +} + +/// Reject paths that hold more than one distinct definition rather than picking one +fn single_definition<'a>( + path: &'a str, + definitions: &'a [DeriveInput], +) -> Result<(&'a str, &'a DeriveInput), ABIError> { + match definitions { + [only] => Ok((path, only)), + _ => Err(ABIError::StructResolution(format!( + "`{path}` has {} conflicting `#[derive(Codec)]` definitions; \ + the generated ABI would depend on which one is picked", + definitions.len() + ))), + } +} + +/// Strip every array suffix from a type name (`Cell[][]` -> `Cell`, `Item[3][2]` -> `Item`) +fn strip_array_suffixes(name: &str) -> &str { + match name.find('[') { + Some(index) => &name[..index], + None => name, + } +} + +#[cfg(test)] +mod tests { + mod parse { + use crate::abi::structs::StructRegistry; + use std::fs; + use tempfile::TempDir; + + /// Helper to create a temporary crate with the given root file content + fn crate_with_root(content: &str) -> (TempDir, std::path::PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("lib.rs"); + fs::write(&file_path, content).unwrap(); + (temp_dir, file_path) + } + + fn parse(content: &str) -> (TempDir, StructRegistry) { + let (temp_dir, file_path) = crate_with_root(content); + let registry = StructRegistry::parse_crate(&file_path).unwrap(); + (temp_dir, registry) + } + + /// Module-qualified paths held by the registry, in sorted order + fn paths(registry: &StructRegistry) -> Vec<&str> { + registry.structs.keys().map(String::as_str).collect() + } + + #[test] + fn test_parse_simple_struct_with_codec() { + let content = r#" +use fluentbase_sdk::codec::Codec; +use fluentbase_sdk::U256; + +#[derive(Codec, Debug, Clone)] +pub struct TestStruct { + pub field1: U256, + pub field2: bool, + pub field3: Address, +} + +#[derive(Debug, Clone)] +pub struct StructWithoutCodec { + pub field1: u32, +} + +#[derive(Codec)] +pub struct AnotherCodecStruct { + pub value: U256, +} +"#; + + let (_temp_dir, structs) = parse(content); + + // Should find exactly the 2 structs with Codec + assert_eq!(paths(&structs), vec!["AnotherCodecStruct", "TestStruct"]); + + // Verify the TestStruct has correct fields + let (_, test_struct) = structs.resolve("TestStruct", "").unwrap().unwrap(); + if let syn::Data::Struct(data) = &test_struct.data { + let field_names: Vec = data + .fields + .iter() + .filter_map(|f| f.ident.as_ref().map(|i| i.to_string())) + .collect(); + + assert_eq!(field_names, vec!["field1", "field2", "field3"]); + } else { + panic!("Expected struct data"); + } + } + + #[test] + fn test_parse_nested_structs() { + let content = r#" +use fluentbase_sdk::codec::Codec; +use fluentbase_sdk::U256; + +mod inner { + use super::*; + + #[derive(Codec)] + pub struct InnerStruct { + pub value: U256, + } +} + +#[derive(Codec, Debug)] +pub struct OuterStruct { + pub inner: inner::InnerStruct, + pub data: U256, +} +"#; + + let (_temp_dir, structs) = parse(content); + + // Should find both inner and outer structs, the inner one module-qualified + assert_eq!(paths(&structs), vec!["OuterStruct", "inner::InnerStruct"]); + + // Both the qualified and the bare name resolve to the same definition + assert!(structs.resolve("inner::InnerStruct", "").unwrap().is_some()); + assert_eq!( + structs.resolve("InnerStruct", "").unwrap().unwrap().0, + "inner::InnerStruct" + ); + } + + #[test] + fn test_empty_file() { + let content = r#" +// Empty file with no structs +use fluentbase_sdk::codec::Codec; +"#; + + let (_temp_dir, structs) = parse(content); + + assert!(paths(&structs).is_empty()); + } + + #[test] + fn test_struct_with_unnamed_fields() { + let content = r#" +use fluentbase_sdk::codec::Codec; +use fluentbase_sdk::U256; + +#[derive(Codec)] +pub struct TupleStruct(pub U256, pub bool); + +#[derive(Codec)] +pub struct UnitStruct; +"#; + + let (_temp_dir, structs) = parse(content); + + assert_eq!(paths(&structs), vec!["TupleStruct", "UnitStruct"]); + } + + #[test] + fn test_duplicate_names_in_different_modules_are_kept_apart() { + let content = r#" +use fluentbase_sdk::codec::Codec; + +mod a { + #[derive(Codec)] + pub struct Config { + pub value: U256, + } +} + +mod b { + #[derive(Codec)] + pub struct Config { + pub owner: Address, + pub flag: bool, + } +} +"#; + + let (_temp_dir, structs) = parse(content); + + assert_eq!(paths(&structs), vec!["a::Config", "b::Config"]); + + // Qualified paths resolve to their own definition + let (path, definition) = structs.resolve("a::Config", "").unwrap().unwrap(); + assert_eq!(path, "a::Config"); + assert_eq!(field_names(definition), vec!["value"]); + + let (path, definition) = structs.resolve("b::Config", "").unwrap().unwrap(); + assert_eq!(path, "b::Config"); + assert_eq!(field_names(definition), vec!["owner", "flag"]); + + // A bare duplicate name is rejected instead of silently picking one + let error = structs.resolve("Config", "").unwrap_err().to_string(); + assert!(error.contains("ambiguous"), "unexpected error: {error}"); + assert!(error.contains("a::Config"), "unexpected error: {error}"); + assert!(error.contains("b::Config"), "unexpected error: {error}"); + } + + #[test] + fn test_bare_name_resolves_within_its_own_module_scope() { + let content = r#" +mod a { + #[derive(Codec)] + pub struct Config { + pub value: U256, + } +} + +mod b { + #[derive(Codec)] + pub struct Config { + pub owner: Address, + } +} +"#; + + let (_temp_dir, structs) = parse(content); + + // The same bare name resolves differently depending on the module it was written in + assert_eq!( + structs.resolve("Config", "a").unwrap().unwrap().0, + "a::Config" + ); + assert_eq!( + structs.resolve("Config", "b").unwrap().unwrap().0, + "b::Config" + ); + } + + #[test] + fn test_unknown_type_resolves_to_none() { + let content = r#" +#[derive(Codec)] +pub struct Known { + pub value: U256, +} +"#; + + let (_temp_dir, structs) = parse(content); + + assert!(structs.resolve("Unknown", "").unwrap().is_none()); + } + + #[test] + fn test_walks_file_modules_and_ignores_undeclared_files() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path(); + + fs::write(src.join("lib.rs"), "mod types;\nmod nested;\n").unwrap(); + fs::write( + src.join("types.rs"), + "#[derive(Codec)] pub struct Config { pub value: U256 }", + ) + .unwrap(); + fs::create_dir(src.join("nested")).unwrap(); + fs::write(src.join("nested").join("mod.rs"), "mod deep;").unwrap(); + fs::write( + src.join("nested").join("deep.rs"), + "#[derive(Codec)] pub struct Config { pub owner: Address }", + ) + .unwrap(); + // Not declared by any `mod`, so it is not part of the crate + fs::write( + src.join("orphan.rs"), + "#[derive(Codec)] pub struct Orphan { pub value: U256 }", + ) + .unwrap(); + + let structs = StructRegistry::parse_crate(&src.join("lib.rs")).unwrap(); + + assert_eq!( + paths(&structs), + vec!["nested::deep::Config", "types::Config"] + ); + } + + #[test] + fn test_module_path_attribute_is_honored() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path(); + + fs::write( + src.join("lib.rs"), + "#[path = \"custom/location.rs\"]\nmod types;\n", + ) + .unwrap(); + fs::create_dir(src.join("custom")).unwrap(); + fs::write( + src.join("custom").join("location.rs"), + "#[derive(Codec)] pub struct Config { pub value: U256 }", + ) + .unwrap(); + + let structs = StructRegistry::parse_crate(&src.join("lib.rs")).unwrap(); + + assert_eq!(paths(&structs), vec!["types::Config"]); + } + + #[test] + fn test_conflicting_definitions_under_one_path_are_rejected() { + let content = r#" +#[cfg(feature = "a")] +#[derive(Codec)] +pub struct Config { + pub value: U256, +} + +#[cfg(not(feature = "a"))] +#[derive(Codec)] +pub struct Config { + pub owner: Address, +} +"#; + + let (_temp_dir, structs) = parse(content); + + let error = structs.resolve("Config", "").unwrap_err().to_string(); + assert!(error.contains("conflicting"), "unexpected error: {error}"); + } + + #[test] + fn test_package_root_is_located_from_the_package_directory() { + let temp_dir = TempDir::new().unwrap(); + let package = temp_dir.path(); + fs::create_dir(package.join("src")).unwrap(); + fs::write( + package.join("src").join("lib.rs"), + "#[derive(Codec)] pub struct Config { pub value: U256 }", + ) + .unwrap(); + + let structs = StructRegistry::parse_package(package).unwrap(); + assert_eq!(paths(&structs), vec!["Config"]); + + let missing = TempDir::new().unwrap(); + let error = StructRegistry::parse_package(missing.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("no crate root"), "unexpected error: {error}"); + } + + fn field_names(definition: &syn::DeriveInput) -> Vec { + match &definition.data { + syn::Data::Struct(data) => data + .fields + .iter() + .filter_map(|f| f.ident.as_ref().map(|i| i.to_string())) + .collect(), + _ => panic!("Expected struct data"), + } + } + } + + mod resolve { + use crate::abi::{parameter::Parameter, structs::StructRegistry}; + use std::fs; + use tempfile::TempDir; + + /// Helper to build a registry from crate root source + fn registry(content: &str) -> (TempDir, StructRegistry) { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("lib.rs"); + fs::write(&file_path, content).unwrap(); + let registry = StructRegistry::parse_crate(&file_path).unwrap(); + (temp_dir, registry) + } + + /// A struct parameter as it comes out of a contract signature: named, but componentless + fn struct_parameter(name: &str, internal_type: &str, ty: &str) -> Parameter { + Parameter { + internal_type: internal_type.to_string(), + ty: ty.to_string(), + name: name.to_string(), + components: None, + } + } + + fn component_names(param: &Parameter) -> Vec<&str> { + param + .components + .as_ref() + .expect("components") + .iter() + .map(|component| component.name.as_str()) + .collect() + } + + #[test] + fn test_resolve_simple_struct_parameter() { + let (_temp, structs) = registry( + r#" +#[derive(Codec)] +pub struct SlippageParams { + pub amount_in: U256, + pub reserve_in: U256, + pub reserve_out: U256, + pub fee_rate: U256, +} +"#, + ); + + let mut param = struct_parameter("params", "struct SlippageParams", "tuple"); + assert!(param.has_unresolved_struct()); + + param.resolve_structs(&structs, "").unwrap(); + + assert!(!param.has_unresolved_struct()); + assert_eq!( + component_names(¶m), + vec!["amount_in", "reserve_in", "reserve_out", "fee_rate"] + ); + for component in param.components.as_ref().unwrap() { + assert_eq!(component.ty, "uint256"); + } + assert_eq!( + param.get_canonical_type().unwrap(), + "(uint256,uint256,uint256,uint256)" + ); + } + + #[test] + fn test_resolve_uses_qualified_path_from_signature() { + let (_temp, structs) = registry( + r#" +mod a { + #[derive(Codec)] + pub struct Config { + pub value: U256, + } +} + +mod b { + #[derive(Codec)] + pub struct Config { + pub owner: Address, + pub flag: bool, + } +} +"#, + ); + + let mut param = struct_parameter("config", "struct b::Config", "tuple"); + param.resolve_structs(&structs, "").unwrap(); + + assert_eq!(component_names(¶m), vec!["owner", "flag"]); + } + + #[test] + fn test_resolve_rejects_ambiguous_bare_name() { + let (_temp, structs) = registry( + r#" +mod a { + #[derive(Codec)] + pub struct Config { + pub value: U256, + } +} + +mod b { + #[derive(Codec)] + pub struct Config { + pub owner: Address, + } +} +"#, + ); + + let mut param = struct_parameter("config", "struct Config", "tuple"); + let error = param.resolve_structs(&structs, "").unwrap_err().to_string(); + assert!(error.contains("ambiguous"), "unexpected error: {error}"); + } + + #[test] + fn test_resolve_rejects_unknown_struct() { + let (_temp, structs) = registry("#[derive(Codec)] pub struct Known { pub v: U256 }"); + + let mut param = struct_parameter("value", "struct Missing", "tuple"); + let error = param.resolve_structs(&structs, "").unwrap_err().to_string(); + assert!( + error.contains("no `#[derive(Codec)]` definition"), + "unexpected error: {error}" + ); + } + + #[test] + fn test_resolve_nested_struct_in_its_own_module() { + // `Outer` lives in `a` and refers to a bare `Inner` that also exists in `b`; + // the nested lookup must stay inside `a`. + let (_temp, structs) = registry( + r#" +mod a { + #[derive(Codec)] + pub struct Inner { + pub value: U256, + } + + #[derive(Codec)] + pub struct Outer { + pub inner: Inner, + } +} + +mod b { + #[derive(Codec)] + pub struct Inner { + pub owner: Address, + pub flag: bool, + } +} +"#, + ); + + let mut param = struct_parameter("outer", "struct a::Outer", "tuple"); + param.resolve_structs(&structs, "").unwrap(); + + let inner = ¶m.components.as_ref().unwrap()[0]; + assert_eq!(component_names(inner), vec!["value"]); + assert_eq!(param.get_canonical_type().unwrap(), "((uint256))"); + } + + #[test] + fn test_resolve_struct_array_parameters() { + let (_temp, structs) = registry( + r#" +#[derive(Codec)] +pub struct Item { + pub id: U256, + pub owner: Address, +} +"#, + ); + + let mut dynamic = struct_parameter("dynamic", "struct Item[]", "tuple[]"); + let mut fixed = struct_parameter("fixed", "struct Item[3]", "tuple[3]"); + + dynamic.resolve_structs(&structs, "").unwrap(); + fixed.resolve_structs(&structs, "").unwrap(); + + for param in [&dynamic, &fixed] { + assert_eq!(component_names(param), vec!["id", "owner"]); + } + assert_eq!(dynamic.get_canonical_type().unwrap(), "(uint256,address)[]"); + assert_eq!(fixed.get_canonical_type().unwrap(), "(uint256,address)[3]"); + } + + #[test] + fn test_recursive_struct_is_rejected() { + let (_temp, structs) = registry( + r#" +#[derive(Codec)] +pub struct Node { + pub children: Vec, +} +"#, + ); + + let mut param = struct_parameter("node", "struct Node", "tuple"); + let error = param.resolve_structs(&structs, "").unwrap_err().to_string(); + assert!(error.contains("recursive"), "unexpected error: {error}"); + } + } +} diff --git a/crates/sdk-derive/derive-core/src/attr/mod.rs b/crates/sdk-derive/derive-core/src/attr/mod.rs index e9425cf00..0bb532e8d 100644 --- a/crates/sdk-derive/derive-core/src/attr/mod.rs +++ b/crates/sdk-derive/derive-core/src/attr/mod.rs @@ -1,7 +1,9 @@ pub(crate) mod artifacts_dir; pub(crate) mod function_id; pub(crate) mod mode; +pub(crate) mod state_mutability; pub use artifacts_dir::Artifacts; pub use function_id::FunctionIDAttribute; pub use mode::Mode; +pub use state_mutability::{StateMutabilityExt, STATE_MUTABILITY_ATTR}; diff --git a/crates/sdk-derive/derive-core/src/attr/state_mutability.rs b/crates/sdk-derive/derive-core/src/attr/state_mutability.rs new file mode 100644 index 000000000..9282e10fa --- /dev/null +++ b/crates/sdk-derive/derive-core/src/attr/state_mutability.rs @@ -0,0 +1,226 @@ +use crate::abi::function::StateMutability; +use syn::{spanned::Spanned, Attribute, FnArg, LitStr, Signature}; + +/// Name of the attribute carrying the Solidity state mutability of a method. +pub const STATE_MUTABILITY_ATTR: &str = "state_mutability"; + +/// All values accepted by the `#[state_mutability(...)]` attribute +const VALID_MUTABILITIES: &[&str] = &["pure", "view", "nonpayable", "payable"]; + +/// Extension helpers describing what a call to a method of this mutability is +/// allowed to do on the host. +pub trait StateMutabilityExt: Sized { + /// Returns true when the callee must not be able to mutate state, i.e. the + /// call has to be issued as a `STATICCALL` + fn is_static(&self) -> bool; + + /// Returns true when native value may be attached to the call + fn allows_value(&self) -> bool; + + /// Returns the canonical Solidity name of the mutability + fn as_str(&self) -> &'static str; + + /// Parses the canonical Solidity name of a mutability + fn from_str(value: &str) -> Option; +} + +impl StateMutabilityExt for StateMutability { + fn is_static(&self) -> bool { + matches!(self, Self::Pure | Self::View) + } + + fn allows_value(&self) -> bool { + matches!(self, Self::Payable) + } + + fn as_str(&self) -> &'static str { + match self { + Self::Pure => "pure", + Self::View => "view", + Self::NonPayable => "nonpayable", + Self::Payable => "payable", + } + } + + fn from_str(value: &str) -> Option { + match value { + "pure" => Some(Self::Pure), + "view" => Some(Self::View), + "nonpayable" => Some(Self::NonPayable), + "payable" => Some(Self::Payable), + _ => None, + } + } +} + +/// Parses the `#[state_mutability("...")]` attribute of a method, falling back +/// to the mutability implied by its receiver. +/// +/// The Solidity front-end (`derive_solidity_client`) emits the attribute +/// explicitly so `pure`/`view` and `nonpayable`/`payable` survive code +/// generation. Hand-written traits only carry the receiver, so `&self` is read +/// as `view` (no state change, no value) and `&mut self` as `payable`, which is +/// the least restrictive mutable form and preserves the historical behavior. +/// +/// # Arguments +/// +/// * `attrs` - The attributes of the method +/// * `sig` - The signature of the method +/// +/// # Returns +/// +/// The resolved state mutability, or an error if the attribute is malformed or +/// contradicts the receiver +pub fn resolve_state_mutability( + attrs: &[Attribute], + sig: &Signature, +) -> syn::Result { + let receiver_mutability = mutability_from_receiver(sig); + + let Some(attr) = attrs + .iter() + .find(|attr| attr.path().is_ident(STATE_MUTABILITY_ATTR)) + else { + return Ok(receiver_mutability); + }; + + let literal = attr.parse_args::().map_err(|_| { + syn::Error::new( + attr.span(), + format!( + "Expected #[{}(\"...\")] with one of: {}", + STATE_MUTABILITY_ATTR, + VALID_MUTABILITIES.join(", ") + ), + ) + })?; + + let declared = StateMutability::from_str(&literal.value()).ok_or_else(|| { + syn::Error::new( + literal.span(), + format!( + "Invalid state mutability '{}'. Valid values are: {}", + literal.value(), + VALID_MUTABILITIES.join(", ") + ), + ) + })?; + + // A `&mut self` receiver promises state access, so it cannot be reconciled + // with a read-only mutability: the two would disagree on the host operation. + if declared.is_static() && has_mutable_receiver(sig) { + return Err(syn::Error::new( + literal.span(), + format!( + "Method declared as '{}' must take '&self', not '&mut self'", + literal.value() + ), + )); + } + + Ok(declared) +} + +/// Returns the mutability implied by a method receiver +fn mutability_from_receiver(sig: &Signature) -> StateMutability { + if has_mutable_receiver(sig) { + StateMutability::Payable + } else { + StateMutability::View + } +} + +/// Returns true if the method takes `&mut self` +fn has_mutable_receiver(sig: &Signature) -> bool { + sig.inputs.iter().any(|arg| match arg { + FnArg::Receiver(receiver) => receiver.mutability.is_some(), + FnArg::Typed(_) => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::{parse_quote, TraitItemFn}; + + fn resolve(method: &TraitItemFn) -> syn::Result { + resolve_state_mutability(&method.attrs, &method.sig) + } + + #[test] + fn test_receiver_drives_default_mutability() { + let immutable: TraitItemFn = parse_quote! { + fn balance_of(&self, owner: Address) -> U256; + }; + assert_eq!(resolve(&immutable).unwrap(), StateMutability::View); + + let mutable: TraitItemFn = parse_quote! { + fn transfer(&mut self, to: Address) -> bool; + }; + assert_eq!(resolve(&mutable).unwrap(), StateMutability::Payable); + } + + #[test] + fn test_attribute_overrides_receiver() { + let nonpayable: TraitItemFn = parse_quote! { + #[state_mutability("nonpayable")] + fn transfer(&mut self, to: Address) -> bool; + }; + assert_eq!(resolve(&nonpayable).unwrap(), StateMutability::NonPayable); + + let pure_fn: TraitItemFn = parse_quote! { + #[state_mutability("pure")] + fn add(&self, a: U256, b: U256) -> U256; + }; + assert_eq!(resolve(&pure_fn).unwrap(), StateMutability::Pure); + } + + #[test] + fn test_read_only_mutability_rejects_mutable_receiver() { + let contradiction: TraitItemFn = parse_quote! { + #[state_mutability("view")] + fn balance_of(&mut self, owner: Address) -> U256; + }; + let err = resolve(&contradiction).unwrap_err(); + assert!( + err.to_string().contains("must take '&self'"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_invalid_mutability_is_rejected() { + let unknown: TraitItemFn = parse_quote! { + #[state_mutability("constant")] + fn balance_of(&self, owner: Address) -> U256; + }; + let err = resolve(&unknown).unwrap_err(); + assert!( + err.to_string().contains("Invalid state mutability"), + "unexpected error: {err}" + ); + + let malformed: TraitItemFn = parse_quote! { + #[state_mutability(view)] + fn balance_of(&self, owner: Address) -> U256; + }; + let err = resolve(&malformed).unwrap_err(); + assert!( + err.to_string().contains("Expected #[state_mutability"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_call_policy_per_mutability() { + assert!(StateMutability::Pure.is_static()); + assert!(StateMutability::View.is_static()); + assert!(!StateMutability::NonPayable.is_static()); + assert!(!StateMutability::Payable.is_static()); + + assert!(!StateMutability::Pure.allows_value()); + assert!(!StateMutability::View.allows_value()); + assert!(!StateMutability::NonPayable.allows_value()); + assert!(StateMutability::Payable.allows_value()); + } +} diff --git a/crates/sdk-derive/derive-core/src/client.rs b/crates/sdk-derive/derive-core/src/client.rs index e9d087e60..b8968b2dd 100644 --- a/crates/sdk-derive/derive-core/src/client.rs +++ b/crates/sdk-derive/derive-core/src/client.rs @@ -1,5 +1,6 @@ use crate::{ - attr::mode::Mode, + abi::structs::StructResolver, + attr::{mode::Mode, StateMutabilityExt}, codec::CodecGenerator, method::{MethodCollector, MethodLike, ParsedMethod}, }; @@ -31,11 +32,20 @@ pub struct Client { /// Parses and validates a client from token streams. pub fn process_client(attr: TokenStream2, input: TokenStream2) -> Result> { + process_client_with_structs(attr, input, &StructResolver::crate_sources()) +} + +/// Parses and validates a client, resolving struct parameters through the given resolver. +pub fn process_client_with_structs( + attr: TokenStream2, + input: TokenStream2, + resolver: &StructResolver, +) -> Result> { let attributes = parse_attributes(attr)?; let trait_def = syn::parse2::(input)?; - let client = Client::new(attributes, trait_def)?; + let client = Client::new(attributes, trait_def, resolver)?; Ok(client) } @@ -47,8 +57,12 @@ fn parse_attributes(attr: TokenStream2) -> Result { } impl Client { - pub fn new(attributes: ClientAttributes, trait_def: ItemTrait) -> Result { - let mut collector = MethodCollector::::new(trait_def.span()); + pub fn new( + attributes: ClientAttributes, + trait_def: ItemTrait, + resolver: &StructResolver, + ) -> Result { + let mut collector = MethodCollector::::new(trait_def.span(), resolver); visit::visit_item_trait(&mut collector, &trait_def); if collector.methods.is_empty() { @@ -233,11 +247,58 @@ impl Client { } }; + // The host operation and the value policy follow the Solidity mutability: + // `view`/`pure` must not be able to mutate state or move funds, so they + // are issued as static calls without a value parameter, and `nonpayable` + // keeps the mutable call but has no way to attach a value. + let mutability = method.state_mutability(); + + let value_param = if mutability.allows_value() { + quote! { value: fluentbase_sdk::U256, } + } else { + quote! {} + }; + + let value_check = if mutability.allows_value() { + quote! { + if context.tx_value() < value { + ::core::panic!("Insufficient funds for transaction"); + } + } + } else { + quote! {} + }; + + let host_call = if mutability.is_static() { + quote! { + self.sdk.static_call( + contract_address, + &input, + Some(gas_limit), + ) + } + } else { + let value = if mutability.allows_value() { + quote! { value } + } else { + quote! { fluentbase_sdk::U256::ZERO } + }; + + quote! { + self.sdk.call( + contract_address, + #value, + &input, + Some(gas_limit), + ) + } + }; + Ok(quote! { pub fn #fn_name( &mut self, contract_address: fluentbase_sdk::Address, - value: fluentbase_sdk::U256, + #value_param gas_limit: u64, #(#params,)* ) -> #return_type { @@ -247,20 +308,13 @@ impl Client { { let context = self.sdk.context(); - if context.tx_value() < value { - ::core::panic!("Insufficient funds for transaction"); - } + #value_check if context.tx_gas_limit() < gas_limit { ::core::panic!("Insufficient gas limit for transaction"); } } - let result = self.sdk.call( - contract_address, - value, - &input, - Some(gas_limit), - ); + let result = #host_call; if !fluentbase_sdk::SyscallResult::is_ok(result.status) { ::core::panic!("Contract call failed"); @@ -297,7 +351,7 @@ mod tests { }; let attributes = ClientAttributes::default(); - let client = Client::new(attributes, trait_def).unwrap(); + let client = Client::new(attributes, trait_def, &StructResolver::default()).unwrap(); let generated = client.generate().unwrap(); @@ -306,4 +360,94 @@ mod tests { assert_snapshot!("generate_client", formatted.to_string()); } + + /// Generates a client for a single method and strips whitespace, so + /// assertions can pin the exact host call without depending on formatting + fn generated_method(method: TraitItemFn) -> String { + let trait_def: ItemTrait = parse_quote! { + pub trait CallPolicy { + #method + } + }; + + let client = Client::new( + ClientAttributes::default(), + trait_def, + &StructResolver::default(), + ) + .unwrap(); + let generated = client.generate().unwrap(); + + generated + .to_string() + .chars() + .filter(|c| !c.is_whitespace()) + .collect() + } + + #[test] + fn test_pure_and_view_methods_issue_static_calls_without_value() { + for method in [ + parse_quote! { + #[state_mutability("pure")] + fn read_only(&self, a: u32) -> u32; + }, + parse_quote! { + #[state_mutability("view")] + fn read_only(&self, a: u32) -> u32; + }, + // Hand-written traits carry no attribute: `&self` is read-only + parse_quote! { + fn read_only(&self, a: u32) -> u32; + }, + ] { + let generated = generated_method(method); + + assert!(generated + .contains("self.sdk.static_call(contract_address,&input,Some(gas_limit),)")); + assert!(!generated.contains("self.sdk.call(")); + // No value can be attached, and none has to be checked + assert!(!generated.contains("value:fluentbase_sdk::U256")); + assert!(!generated.contains("tx_value()")); + } + } + + #[test] + fn test_nonpayable_methods_call_with_zero_value() { + let generated = generated_method(parse_quote! { + #[state_mutability("nonpayable")] + fn mutate(&mut self, a: u32) -> u32; + }); + + assert!(generated.contains( + "self.sdk.call(contract_address,fluentbase_sdk::U256::ZERO,&input,Some(gas_limit),)" + )); + assert!(!generated.contains("static_call")); + assert!(!generated.contains("value:fluentbase_sdk::U256")); + assert!(!generated.contains("tx_value()")); + } + + #[test] + fn test_payable_methods_forward_the_requested_value() { + for method in [ + parse_quote! { + #[state_mutability("payable")] + fn mutate(&mut self, a: u32) -> u32; + }, + // Hand-written traits carry no attribute: `&mut self` keeps the + // historical behavior of forwarding a caller-supplied value + parse_quote! { + fn mutate(&mut self, a: u32) -> u32; + }, + ] { + let generated = generated_method(method); + + assert!(generated.contains("value:fluentbase_sdk::U256,gas_limit:u64")); + assert!( + generated.contains("self.sdk.call(contract_address,value,&input,Some(gas_limit),)") + ); + assert!(generated.contains("context.tx_value() CodecGenerator<'a, T> { let crate_path = self.get_crate_path(); let codec_type = self.get_codec_type(); let selector = self.route.function_id(); - let signature = self.route.parsed_signature().function_abi()?.signature()?; + // The signature the selector was hashed from, so the two never describe different calls + let signature = self.route.signature(); // Encode method (with or without selector) let encode_method = if self.route.is_constructor() { @@ -209,12 +210,13 @@ impl<'a, T: MethodLike> CodecGenerator<'a, T> { #[cfg(test)] mod tests { use super::*; + use crate::abi::structs::StructResolver; use insta::assert_snapshot; use proc_macro2::TokenStream as TokenStream2; use syn::{parse_file, parse_quote, ImplItemFn}; fn create_route(item: ImplItemFn) -> ParsedMethod { - ParsedMethod::from_ref(&item).unwrap() + ParsedMethod::from_ref(&item, &StructResolver::default()).unwrap() } fn create_generator( diff --git a/crates/sdk-derive/derive-core/src/constructor.rs b/crates/sdk-derive/derive-core/src/constructor.rs index 2679ad391..486472e4a 100644 --- a/crates/sdk-derive/derive-core/src/constructor.rs +++ b/crates/sdk-derive/derive-core/src/constructor.rs @@ -1,11 +1,11 @@ use crate::{ + abi::structs::StructResolver, attr::mode::Mode, codec::CodecGenerator, - method::{MethodCollector, ParsedMethod}, + method::{combine_errors, MethodCollector, ParsedMethod}, }; use darling::{ast::NestedMeta, FromMeta}; use proc_macro2::{Span, TokenStream as TokenStream2}; -use proc_macro_error::{abort, abort_call_site, emit_error}; use quote::{quote, ToTokens}; use syn::{spanned::Spanned, visit, Error, ImplItemFn, ItemImpl, Result}; @@ -30,10 +30,19 @@ pub struct Constructor { /// Parses and validates a constructor from token streams. pub fn process_constructor(attr: TokenStream2, input: TokenStream2) -> Result { + process_constructor_with_structs(attr, input, &StructResolver::crate_sources()) +} + +/// Parses and validates a constructor, resolving struct parameters through the given resolver. +pub fn process_constructor_with_structs( + attr: TokenStream2, + input: TokenStream2, + resolver: &StructResolver, +) -> Result { let attributes = parse_attributes(attr)?; let impl_block = syn::parse2::(input)?; - Constructor::new(attributes, impl_block) + Constructor::new(attributes, impl_block, resolver) } /// Parses constructor attributes from a TokenStream. @@ -45,33 +54,35 @@ fn parse_attributes(attr: TokenStream2) -> Result { impl Constructor { /// Creates a new Constructor instance by parsing the implementation block. - pub fn new(attributes: ConstructorAttributes, impl_block: ItemImpl) -> Result { + pub fn new( + attributes: ConstructorAttributes, + impl_block: ItemImpl, + resolver: &StructResolver, + ) -> Result { // Use the existing MethodCollector to find the constructor let is_trait_impl = impl_block.trait_.is_some(); let mut collector = - MethodCollector::::new_for_impl(impl_block.span(), is_trait_impl); + MethodCollector::::new_for_impl(impl_block.span(), is_trait_impl, resolver); visit::visit_item_impl(&mut collector, &impl_block); + // Errors are returned rather than reported through `proc_macro_error`, because the build + // tooling drives this same code outside of a proc-macro expansion + if let Some(error) = combine_errors(std::mem::take(&mut collector.errors)) { + return Err(error); + } + // Validate we have exactly one constructor if collector.constructor.is_none() { - abort!( + return Err(Error::new( impl_block.span(), - "No constructor method found in implementation block"; - help = "Add a method named 'constructor' to initialize the contract"; - help = "Example: pub fn constructor(&mut self, initial_value: U256) {{ ... }}" - ); - } - - // Check for any errors during collection - if collector.has_errors() { - for err in &collector.errors { - emit_error!(err.span(), "{}", err.to_string()); - } - abort_call_site!("Failed to process constructor due to parsing errors"); + "No constructor method found in implementation block\n\ + help: Add a method named 'constructor' to initialize the contract\n\ + help: Example: pub fn constructor(&mut self, initial_value: U256) { ... }", + )); } - // Warn if regular methods were found (they'll be ignored) + // Reject regular methods, which this macro would otherwise silently ignore if !collector.methods.is_empty() { let method_names: Vec = collector .methods @@ -79,14 +90,16 @@ impl Constructor { .map(|m| m.parsed_signature().rust_name()) .collect(); - emit_error!( + return Err(Error::new( impl_block.span(), - "Found {} non-constructor methods that will be ignored: {}", - collector.methods.len(), - method_names.join(", "); - note = "The #[constructor] macro only processes the 'constructor' method"; - help = "Use #[router] macro if you need to handle other methods" - ); + format!( + "Found {} non-constructor methods that will be ignored: {}\n\ + note: The #[constructor] macro only processes the 'constructor' method\n\ + help: Use #[router] macro if you need to handle other methods", + collector.methods.len(), + method_names.join(", ") + ), + )); } let constructor_method = collector diff --git a/crates/sdk-derive/derive-core/src/event.rs b/crates/sdk-derive/derive-core/src/event.rs index 5ac1b3b32..0112edb2a 100644 --- a/crates/sdk-derive/derive-core/src/event.rs +++ b/crates/sdk-derive/derive-core/src/event.rs @@ -164,8 +164,6 @@ fn generate_event_impl(event: &ParsedEvent) -> Result { /// Emits this event as an EVM log. pub fn emit(&self, sdk: &mut SDK) -> Result<(), fluentbase_sdk::ExitCode> { - use fluentbase_sdk::codec::SolidityABI; - let topics: [fluentbase_sdk::B256; #topic_count] = #topics_code; #data_code sdk.emit_log(&topics, &data).ok() @@ -176,13 +174,15 @@ fn generate_event_impl(event: &ParsedEvent) -> Result { /// Generates topic encoding for indexed fields. /// -/// Topics are 32-byte values used for bloom filter indexing: -/// - Static types (address, uint256, bool, etc.): ABI-encoded directly (always 32 bytes) -/// - Dynamic types (string, bytes, arrays, dynamic structs): keccak256 of ABI-encoded value -/// (computed at runtime via SDK::keccak256) +/// Topics are 32-byte values used for bloom filter indexing, so Solidity splits indexed +/// parameters by *type category* rather than by whether the ABI encoding is dynamic: +/// - Value types (address, uint256, bool, bytesN, etc.): the ABI word is the topic. +/// - Reference types (string, bytes, arrays — fixed ones included — and structs): the topic is +/// keccak256 of a preimage that is not ordinary ABI encoding, hashed at runtime via +/// SDK::keccak256. /// -/// This distinction exists because bloom filters require fixed-size data. -/// Dynamic types are hashed, losing original value but enabling equality filtering. +/// `fluentbase_sdk::codec::encode_indexed_topic` builds both cases; hashing stays here because it +/// goes through the host function rather than a software keccak256. fn generate_topics(indexed: &[&EventField], anonymous: bool, selector: &[u8; 32]) -> TokenStream2 { let mut exprs = Vec::new(); @@ -193,22 +193,19 @@ fn generate_topics(indexed: &[&EventField], anonymous: bool, selector: &[u8; 32] for field in indexed { let name = &field.name; - let ty = &field.ty; exprs.push(quote! { { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.#name.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - - if SolidityABI::<#ty>::is_dynamic() { - // Dynamic type: hash at runtime via SDK - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - // Static type: use ABI-encoded value directly (32 bytes) - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.#name) + .expect("encode indexed field"); + + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } } }); @@ -219,7 +216,11 @@ fn generate_topics(indexed: &[&EventField], anonymous: bool, selector: &[u8; 32] /// Generates data encoding for non-indexed fields. /// -/// Data section contains ABI-encoded tuple of all non-indexed fields. +/// Data section contains the non-indexed fields encoded with top-level argument semantics, +/// exactly like Solidity's `abi.encode(arg0, arg1, ...)`. This is *not* the same as encoding +/// the fields as a single tuple value: a dynamic tuple value carries an extra outer offset +/// word that standard log decoders do not expect, so `encode_function_args` is used to drop it. +/// /// Unlike topics, data preserves full values but cannot be filtered via bloom filter. fn generate_data(fields: &[&EventField]) -> TokenStream2 { if fields.is_empty() { @@ -232,7 +233,8 @@ fn generate_data(fields: &[&EventField]) -> TokenStream2 { let data = { let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); let values = (#(self.#names.clone(),)*); - SolidityABI::encode(&values, &mut buf, 0).expect("encode data fields"); + fluentbase_sdk::codec::SolidityABI::encode_function_args(&values, &mut buf) + .expect("encode data fields"); buf.freeze() }; } @@ -321,6 +323,20 @@ mod tests { assert_snapshot!(generate(input)); } + #[test] + fn test_mixed_static_dynamic_data() { + let input: DeriveInput = parse_quote! { + struct Mixed { + #[indexed] + who: Address, + amount: U256, + note: String, + extra: Vec
, + } + }; + assert_snapshot!(generate(input)); + } + #[test] fn test_too_many_indexed_regular() { let input: DeriveInput = parse_quote! { diff --git a/crates/sdk-derive/derive-core/src/lib.rs b/crates/sdk-derive/derive-core/src/lib.rs index 7eecddcf0..6f5bf7e19 100644 --- a/crates/sdk-derive/derive-core/src/lib.rs +++ b/crates/sdk-derive/derive-core/src/lib.rs @@ -7,9 +7,9 @@ pub mod client; mod codec; pub mod constructor; pub mod event; -mod method; +pub mod method; pub mod router; -mod signature; +pub mod signature; pub mod sol_input; pub mod storage; // #[deprecated( diff --git a/crates/sdk-derive/derive-core/src/method.rs b/crates/sdk-derive/derive-core/src/method.rs index efa003f7e..0937b231b 100644 --- a/crates/sdk-derive/derive-core/src/method.rs +++ b/crates/sdk-derive/derive-core/src/method.rs @@ -1,5 +1,12 @@ use crate::{ - attr::{function_id::FunctionID, FunctionIDAttribute}, + abi::{ + error::ABIError, + function::{FunctionABI, StateMutability}, + structs::StructResolver, + }, + attr::{ + function_id::FunctionID, state_mutability::resolve_state_mutability, FunctionIDAttribute, + }, signature::ParsedSignature, }; use proc_macro2::{Span, TokenStream as TokenStream2}; @@ -83,6 +90,11 @@ pub trait MethodLike: Sized { }) .transpose() } + + /// Resolves the Solidity state mutability of the method + fn state_mutability(&self) -> syn::Result { + resolve_state_mutability(self.attrs(), self.sig()) + } } // Implement MethodLike for TraitItemFn @@ -112,70 +124,120 @@ impl MethodLike for ImplItemFn { pub struct ParsedMethod { /// Function ID calculated from the method signature function_id: FunctionID, + /// Canonical Solidity signature the function ID was calculated from + signature: String, + /// ABI the signature was derived from, with struct parameters resolved + /// + /// `None` only when a custom selector stands in for a signature that cannot be derived here, + /// in which case there is no ABI entry to publish for this method either. + abi: Option, /// Parsed signature of the method sig: ParsedSignature, + /// Solidity state mutability, deciding which host call the method may issue + state_mutability: StateMutability, /// Inner function implementation inner: T, } impl ParsedMethod { /// Creates a new ParsedMethod with given inner implementation - pub fn new(inner: T) -> syn::Result { + /// + /// `resolver` supplies the struct definitions needed to expand struct parameters into their + /// components; the selector is calculated from that expanded form, so it matches the signature + /// callers and the published ABI use. + pub fn new(inner: T, resolver: &StructResolver) -> syn::Result { let sig = ParsedSignature::new(inner.sig().clone()); - let function_id = sig.function_abi()?.function_id()?; + let state_mutability = inner.state_mutability()?; + let attr = inner.function_id_attr()?; + + // A custom selector without validation is authoritative, and is also the escape hatch for + // signatures this macro cannot derive on its own - a struct declared in another crate, for + // instance. Deriving the ABI must therefore not be a precondition for using it. + if let Some((attr, _)) = &attr { + if !attr.is_validation_enabled() { + let function_id = attr.function_id_bytes()?; + let abi = sig.function_abi_with(resolver).ok(); + let signature = attr + .signature() + .or_else(|| abi.as_ref().and_then(|abi| abi.signature().ok())) + .unwrap_or_else(|| format!("0x{}", hex::encode(function_id))); + + return Ok(Self { + function_id, + signature, + abi, + sig, + state_mutability, + inner, + }); + } + } + + let abi = sig.function_abi_with(resolver).map_err(|error| { + let help = matches!(error, ABIError::StructResolution(_)).then_some( + "\nhelp: define the struct in this crate, or pin the selector with \ + #[function_id(\"name((...))\")] using the components callers encode", + ); + + syn::Error::new(sig.span(), format!("{error}{}", help.unwrap_or_default())) + })?; + let signature = abi.signature()?; + let function_id = abi.function_id()?; - // Handle custom function ID if defined via attribute - if let Some((attr, attr_span)) = inner.function_id_attr()? { + // Validation is enabled, so the attribute has to agree with the derived signature + if let Some((attr, attr_span)) = &attr { let function_id_attr = attr.function_id_bytes()?; - if attr.is_validation_enabled() && function_id_attr != function_id { + if function_id_attr != function_id { abort!( - attr_span, + *attr_span, "Function ID mismatch: Expected 0x{} for '{}', but got 0x{}", hex::encode(function_id), - sig.function_abi()?.signature()?, + signature, hex::encode(function_id_attr); note = "You're seeing this error because you have validation enabled (validate(true))"; help = "To fix this, you can either:"; - help = "1. Use the expected function ID: #[function_id(\"{}\")]", sig.function_abi()?.signature()?; + help = "1. Use the expected function ID: #[function_id(\"{}\")]", signature; help = "2. Remove the validate parameter entirely: #[function_id(\"{}\")]", attr.signature().unwrap_or_else(|| "your_signature".to_string()); help = "3. Or explicitly disable validation: #[function_id(\"{}\", validate(false))]", attr.signature().unwrap_or_else(|| "your_signature".to_string()) ); - } else if !attr.is_validation_enabled() { - // If validation is disabled, use the function ID from the attribute - return Ok(Self { - function_id: function_id_attr, - sig, - inner, - }); } } Ok(Self { function_id, + signature, + abi: Some(abi), sig, + state_mutability, inner, }) } /// Creates a new ParsedMethod for constructor (without function_id calculation) - pub fn new_constructor(inner: T) -> syn::Result { + pub fn new_constructor(inner: T, resolver: &StructResolver) -> syn::Result { let sig = ParsedSignature::new(inner.sig().clone()); + let state_mutability = inner.state_mutability()?; + let abi = sig.function_abi_with(resolver)?; + let signature = abi.signature()?; // For constructor, use zero function_id as it doesn't need a selector Ok(Self { function_id: [0, 0, 0, 0], + signature, + abi: Some(abi), sig, + state_mutability, inner, }) } /// Creates a new ParsedMethod from a reference - pub fn from_ref(inner: &T) -> syn::Result + pub fn from_ref(inner: &T, resolver: &StructResolver) -> syn::Result where T: Clone, { - Self::new(inner.clone()) + Self::new(inner.clone(), resolver) } /// Returns the function ID @@ -183,6 +245,16 @@ impl ParsedMethod { self.function_id } + /// Returns the canonical signature the function ID was calculated from + pub fn signature(&self) -> &str { + &self.signature + } + + /// Returns the ABI of the method, with struct parameters resolved + pub fn function_abi(&self) -> Option<&FunctionABI> { + self.abi.as_ref() + } + /// Returns a reference to the inner signature pub fn sig(&self) -> &Signature { self.inner.sig() @@ -193,6 +265,11 @@ impl ParsedMethod { &self.sig } + /// Returns the Solidity state mutability of the method + pub fn state_mutability(&self) -> StateMutability { + self.state_mutability + } + /// Returns a reference to the inner implementation pub fn inner(&self) -> &T { &self.inner @@ -270,8 +347,19 @@ impl ParsedMethod { } } +/// Folds collected errors into a single `syn::Error`, keeping every span +/// +/// Diagnostics travel as values instead of `proc_macro_error` aborts because the build tooling +/// parses the very same routers outside of a proc-macro expansion, where aborting panics. +pub fn combine_errors(errors: Vec) -> Option { + errors.into_iter().reduce(|mut combined, error| { + combined.combine(error); + combined + }) +} + /// Collector for gathering methods from trait or impl blocks -pub struct MethodCollector { +pub struct MethodCollector<'a, T: MethodLike> { /// Collected methods pub methods: Vec>, /// Constructor method @@ -284,11 +372,13 @@ pub struct MethodCollector { pub selectors: HashSet, /// Whether this is a trait implementation (for router) pub is_trait_impl: bool, + /// Struct definitions used to expand struct parameters before hashing selectors + resolver: &'a StructResolver, } -impl MethodCollector { +impl<'a, T: MethodLike> MethodCollector<'a, T> { /// Creates a new method collector for trait methods - pub fn new(span: Span) -> Self { + pub fn new(span: Span, resolver: &'a StructResolver) -> Self { Self { methods: Vec::new(), constructor: None, @@ -296,11 +386,12 @@ impl MethodCollector { errors: Vec::new(), selectors: HashSet::new(), is_trait_impl: false, + resolver, } } /// Creates a new method collector for impl methods with trait flag - pub fn new_for_impl(span: Span, is_trait_impl: bool) -> Self { + pub fn new_for_impl(span: Span, is_trait_impl: bool, resolver: &'a StructResolver) -> Self { Self { methods: Vec::new(), constructor: None, @@ -308,6 +399,7 @@ impl MethodCollector { errors: Vec::new(), selectors: HashSet::new(), is_trait_impl, + resolver, } } @@ -409,7 +501,7 @@ impl MethodCollector { } // Create ParsedMethod for constructor without function_id calculation - match ParsedMethod::new_constructor(method.clone()) { + match ParsedMethod::new_constructor(method.clone(), self.resolver) { Ok(parsed_constructor) => { self.constructor = Some(parsed_constructor); } @@ -427,7 +519,7 @@ impl MethodCollector { where T: Clone, { - match ParsedMethod::from_ref(method) { + match ParsedMethod::from_ref(method, self.resolver) { Ok(parsed_method) => { self.add_method(parsed_method); } @@ -441,7 +533,7 @@ impl MethodCollector { } } // Implementation for MethodCollector -impl Visit<'_> for MethodCollector { +impl Visit<'_> for MethodCollector<'_, TraitItemFn> { fn visit_trait_item_fn(&mut self, method: &TraitItemFn) { match method.sig.ident.to_string().as_str() { // Reserved methods that cannot be defined by user @@ -469,7 +561,7 @@ impl Visit<'_> for MethodCollector { } // Implementation for MethodCollector -impl Visit<'_> for MethodCollector { +impl Visit<'_> for MethodCollector<'_, ImplItemFn> { fn visit_impl_item_fn(&mut self, method: &ImplItemFn) { match method.sig.ident.to_string().as_str() { // Reserved methods that cannot be defined by user in router @@ -509,7 +601,7 @@ impl Visit<'_> for MethodCollector { // Implement From for ImplItemFn impl From for ParsedMethod { fn from(function: ImplItemFn) -> Self { - match Self::new(function) { + match Self::new(function, &StructResolver::default()) { Ok(method) => method, Err(err) => abort_call_site!("Failed to parse method: {}", err), } @@ -519,7 +611,7 @@ impl From for ParsedMethod { // Implement From for reference to ImplItemFn impl From<&ImplItemFn> for ParsedMethod { fn from(function: &ImplItemFn) -> Self { - match Self::from_ref(function) { + match Self::from_ref(function, &StructResolver::default()) { Ok(method) => method, Err(err) => abort_call_site!("Failed to parse method from reference: {}", err), } @@ -529,7 +621,7 @@ impl From<&ImplItemFn> for ParsedMethod { // Implement From for TraitItemFn impl From for ParsedMethod { fn from(function: TraitItemFn) -> Self { - match Self::new(function) { + match Self::new(function, &StructResolver::default()) { Ok(method) => method, Err(err) => abort_call_site!("Failed to parse method: {}", err), } @@ -539,7 +631,7 @@ impl From for ParsedMethod { // Implement From for reference to TraitItemFn impl From<&TraitItemFn> for ParsedMethod { fn from(function: &TraitItemFn) -> Self { - match Self::from_ref(function) { + match Self::from_ref(function, &StructResolver::default()) { Ok(method) => method, Err(err) => abort_call_site!("Failed to parse method from reference: {}", err), } @@ -552,7 +644,8 @@ mod tests { #[test] fn test_validate_fallback_signature() { - let collector = MethodCollector::::new(Span::call_site()); + let resolver = StructResolver::default(); + let collector = MethodCollector::::new(Span::call_site(), &resolver); // Valid fallback signature let valid_sig: Signature = parse_quote! { @@ -581,7 +674,8 @@ mod tests { #[test] fn test_function_id_collision() { - let mut collector = MethodCollector::::new(Span::call_site()); + let resolver = StructResolver::default(); + let mut collector = MethodCollector::::new(Span::call_site(), &resolver); // Create two trait methods with the same function ID selector let trait_fn1: TraitItemFn = parse_quote! { @@ -612,19 +706,22 @@ mod tests { #[test] fn test_from_ref_impl() { + let resolver = StructResolver::default(); // Create a simple impl item function let impl_fn: ImplItemFn = parse_quote! { pub fn simple_function(&self) {} }; // Test that from_ref works without cloning unnecessarily - let result = ParsedMethod::from_ref(&impl_fn); + let result = ParsedMethod::from_ref(&impl_fn, &resolver); assert!(result.is_ok()); } #[test] fn test_constructor_method_collection() { - let mut collector = MethodCollector::::new_for_impl(Span::call_site(), false); + let resolver = StructResolver::default(); + let mut collector = + MethodCollector::::new_for_impl(Span::call_site(), false, &resolver); // Create a constructor method let constructor_fn: ImplItemFn = parse_quote! { @@ -648,7 +745,9 @@ mod tests { #[test] fn test_multiple_constructors_error() { - let mut collector = MethodCollector::::new_for_impl(Span::call_site(), false); + let resolver = StructResolver::default(); + let mut collector = + MethodCollector::::new_for_impl(Span::call_site(), false, &resolver); // Create two constructor methods let constructor1: ImplItemFn = parse_quote! { @@ -674,7 +773,9 @@ mod tests { #[test] fn test_deploy_method_forbidden() { - let mut collector = MethodCollector::::new_for_impl(Span::call_site(), false); + let resolver = StructResolver::default(); + let mut collector = + MethodCollector::::new_for_impl(Span::call_site(), false, &resolver); // Create a deploy method (should be forbidden) let deploy_fn: ImplItemFn = parse_quote! { @@ -698,7 +799,9 @@ mod tests { #[test] fn test_constructor_with_regular_methods() { - let mut collector = MethodCollector::::new_for_impl(Span::call_site(), false); + let resolver = StructResolver::default(); + let mut collector = + MethodCollector::::new_for_impl(Span::call_site(), false, &resolver); // Create a mix of methods let constructor_fn: ImplItemFn = parse_quote! { @@ -738,12 +841,13 @@ mod tests { #[test] fn test_parsed_method_is_constructor() { + let resolver = StructResolver::default(); // Test constructor method let constructor_fn: ImplItemFn = parse_quote! { pub fn constructor(&mut self, value: u32) {} }; - let parsed_constructor = ParsedMethod::new_constructor(constructor_fn).unwrap(); + let parsed_constructor = ParsedMethod::new_constructor(constructor_fn, &resolver).unwrap(); assert!(parsed_constructor.is_constructor()); assert_eq!(parsed_constructor.function_id(), [0, 0, 0, 0]); @@ -752,7 +856,7 @@ mod tests { pub fn transfer(&mut self, to: Address, amount: u32) {} }; - let parsed_regular = ParsedMethod::new(regular_fn).unwrap(); + let parsed_regular = ParsedMethod::new(regular_fn, &resolver).unwrap(); assert!(!parsed_regular.is_constructor()); assert_ne!(parsed_regular.function_id(), [0, 0, 0, 0]); } diff --git a/crates/sdk-derive/derive-core/src/router.rs b/crates/sdk-derive/derive-core/src/router.rs index 0d808b631..6e89e596f 100644 --- a/crates/sdk-derive/derive-core/src/router.rs +++ b/crates/sdk-derive/derive-core/src/router.rs @@ -1,12 +1,12 @@ use crate::{ - attr::mode::Mode, + abi::structs::StructResolver, + attr::{mode::Mode, STATE_MUTABILITY_ATTR}, codec::CodecGenerator, - method::{MethodCollector, ParsedMethod}, + method::{combine_errors, MethodCollector, ParsedMethod}, }; use convert_case::{Case, Casing}; use darling::{ast::NestedMeta, FromMeta}; use proc_macro2::{Span, TokenStream as TokenStream2}; -use proc_macro_error::{abort, abort_call_site, emit_error}; use quote::{format_ident, quote, ToTokens}; use syn::{spanned::Spanned, visit, Error, Ident, ImplItemFn, ItemImpl, Result}; /// Attributes for the router configuration. @@ -33,12 +33,27 @@ pub struct Router { } /// Parses and validates a router from token streams. +/// +/// Struct parameters are expanded from the sources of the crate being compiled, so the selectors +/// baked into the dispatch table match the ones callers derive from the published ABI. pub fn process_router(attr: TokenStream2, input: TokenStream2) -> Result { + process_router_with_structs(attr, input, &StructResolver::crate_sources()) +} + +/// Parses and validates a router, resolving struct parameters through the given resolver. +/// +/// Build tooling uses this to reuse the registry it has already parsed, which is what keeps the +/// artifacts it generates in step with the router the macro compiles. +pub fn process_router_with_structs( + attr: TokenStream2, + input: TokenStream2, + resolver: &StructResolver, +) -> Result { let attributes = parse_attributes(attr)?; let impl_block = syn::parse2::(input)?; - let router = Router::new(attributes, impl_block)?; + let router = Router::new(attributes, impl_block, resolver)?; Ok(router) } @@ -51,45 +66,52 @@ fn parse_attributes(attr: TokenStream2) -> Result { impl Router { /// Creates a new Router instance by parsing the implementation block. - pub fn new(attributes: RouterAttributes, impl_block: ItemImpl) -> Result { + pub fn new( + attributes: RouterAttributes, + impl_block: ItemImpl, + resolver: &StructResolver, + ) -> Result { let is_trait_impl = impl_block.trait_.is_some(); let mut collector = - MethodCollector::::new_for_impl(impl_block.span(), is_trait_impl); + MethodCollector::::new_for_impl(impl_block.span(), is_trait_impl, resolver); visit::visit_item_impl(&mut collector, &impl_block); - if collector.methods.is_empty() && collector.constructor.is_none() { - abort!( - collector.span, - "Router has no methods or constructor. Make sure your implementation contains at least one public method or a constructor."; - help = "Check that methods are public (pub fn) for regular implementations"; - help = if is_trait_impl { - "For trait implementations, make sure the trait contains method declarations" - } else { - "Consider marking your methods as public: pub fn method_name(...)" - } - ); + // Errors are returned rather than reported through `proc_macro_error`, because the build + // tooling drives this same code outside of a proc-macro expansion. They are also reported + // before the emptiness check, since a method that failed to parse was never collected. + if let Some(error) = combine_errors(std::mem::take(&mut collector.errors)) { + return Err(error); } - if collector.has_errors() { - for err in &collector.errors { - emit_error!(err.span(), "{}", err.to_string()); - } + if collector.methods.is_empty() && collector.constructor.is_none() { + let help = if is_trait_impl { + "For trait implementations, make sure the trait contains method declarations" + } else { + "Consider marking your methods as public: pub fn method_name(...)" + }; - abort_call_site!( - "Failed to process router implementation due to method parsing errors" - ); + return Err(Error::new( + collector.span, + format!( + "Router has no methods or constructor. Make sure your implementation contains \ + at least one public method or a constructor.\n\ + help: Check that methods are public (pub fn) for regular implementations\n\ + help: {help}" + ), + )); } if let Err(collision_error) = collector.validate_selectors() { - abort!( + return Err(Error::new( collision_error.span(), - "{}", - collision_error.to_string(); - help = "Function selectors must be unique across all methods"; - help = "You can use custom selectors with #[function_id(\"custom_signature\")]"; - help = "Or rename your methods to have different signatures" - ); + format!( + "{collision_error}\n\ + help: Function selectors must be unique across all methods\n\ + help: You can use custom selectors with #[function_id(\"custom_signature\")]\n\ + help: Or rename your methods to have different signatures" + ), + )); } Ok(Self { @@ -201,9 +223,10 @@ impl Router { for item in &mut clean_impl_block.items { if let syn::ImplItem::Fn(method) = item { - method - .attrs - .retain(|attr| !attr.path().is_ident("function_id")); + method.attrs.retain(|attr| { + !attr.path().is_ident("function_id") + && !attr.path().is_ident(STATE_MUTABILITY_ATTR) + }); } } @@ -453,7 +476,136 @@ mod tests { use insta::assert_snapshot; use prettyplease; use quote::quote; + use std::fs; use syn::{parse_file, parse_quote}; + use tempfile::TempDir; + + /// A resolver over a crate consisting of the given root source + fn resolver_for(source: &str) -> (TempDir, StructResolver) { + let temp_dir = TempDir::new().unwrap(); + let entry_file = temp_dir.path().join("lib.rs"); + fs::write(&entry_file, source).unwrap(); + let registry = crate::abi::structs::StructRegistry::parse_crate(&entry_file).unwrap(); + (temp_dir, StructResolver::registry(registry)) + } + + /// A struct the crate does not define cannot be hashed into a selector, so it fails the build + /// instead of silently collapsing into an empty tuple + #[test] + fn test_unresolved_struct_parameter_is_rejected() { + let impl_block: syn::ItemImpl = parse_quote! { + impl App { + pub fn create_user(&mut self, user: User) -> bool { + true + } + } + }; + + let (_temp, resolver) = resolver_for("#[derive(Codec)] pub struct Other { pub v: U256 }"); + + let error = process_router_with_structs( + quote! { mode = "solidity" }, + impl_block.into_token_stream(), + &resolver, + ) + .expect_err("an unresolved struct parameter should fail") + .to_string(); + + assert!( + error.contains("no `#[derive(Codec)]` definition"), + "unexpected error: {error}" + ); + } + + /// A struct whose name matches several modules is rejected rather than resolved arbitrarily + #[test] + fn test_ambiguous_struct_parameter_is_rejected() { + let impl_block: syn::ItemImpl = parse_quote! { + impl App { + pub fn set(&mut self, config: Config) -> bool { + true + } + } + }; + + let (_temp, resolver) = resolver_for( + r#" +mod a { + #[derive(Codec)] + pub struct Config { pub value: U256 } +} + +mod b { + #[derive(Codec)] + pub struct Config { pub owner: Address } +} +"#, + ); + + let error = process_router_with_structs( + quote! { mode = "solidity" }, + impl_block.into_token_stream(), + &resolver, + ) + .expect_err("an ambiguous struct parameter should fail") + .to_string(); + + assert!(error.contains("ambiguous"), "unexpected error: {error}"); + } + + /// Struct components are expanded before the selector is hashed + #[test] + fn test_struct_parameter_selector_uses_resolved_components() { + let impl_block: syn::ItemImpl = parse_quote! { + impl App { + pub fn set_a(&mut self, config: Config) -> bool { + true + } + } + }; + + let (_temp, resolver) = resolver_for( + "#[derive(Codec)] pub struct Config { pub value: U256, pub enabled: bool }", + ); + + let router = process_router_with_structs( + quote! { mode = "solidity" }, + impl_block.into_token_stream(), + &resolver, + ) + .expect("Failed to process router"); + + let method = router.available_methods()[0]; + assert_eq!(method.signature(), "setA((uint256,bool))"); + // keccak256("setA((uint256,bool))")[..4] + assert_eq!(method.function_id(), [0xb6, 0xea, 0x7d, 0x04]); + } + + /// A pinned selector still works when the struct cannot be resolved here at all + #[test] + fn test_function_id_pins_the_selector_of_an_unresolved_struct() { + let impl_block: syn::ItemImpl = parse_quote! { + impl App { + #[function_id("setA((uint256,bool))")] + pub fn set_a(&mut self, config: external_crate::Config) -> bool { + true + } + } + }; + + let (_temp, resolver) = resolver_for(""); + + let router = process_router_with_structs( + quote! { mode = "solidity" }, + impl_block.into_token_stream(), + &resolver, + ) + .expect("a pinned selector should not need the struct definition"); + + let method = router.available_methods()[0]; + assert_eq!(method.signature(), "setA((uint256,bool))"); + assert_eq!(method.function_id(), [0xb6, 0xea, 0x7d, 0x04]); + } #[test] fn test_trait_router_generation() { diff --git a/crates/sdk-derive/derive-core/src/signature.rs b/crates/sdk-derive/derive-core/src/signature.rs index 6fb59c422..1bdcbf664 100644 --- a/crates/sdk-derive/derive-core/src/signature.rs +++ b/crates/sdk-derive/derive-core/src/signature.rs @@ -1,5 +1,10 @@ -use crate::abi::{constructor::ConstructorABI, error::ABIError, function::FunctionABI}; -use crate::method::CONSTRUCTOR_METHOD; +use crate::{ + abi::{ + constructor::ConstructorABI, error::ABIError, function::FunctionABI, + structs::StructResolver, + }, + method::CONSTRUCTOR_METHOD, +}; use convert_case::{Case, Casing}; use quote::ToTokens; use std::ops::Deref; @@ -97,14 +102,31 @@ impl ParsedSignature { .collect() } - /// Returns the ABI representation of the function + /// Returns the ABI representation of the function, without resolving struct parameters + /// + /// Anything that hashes a selector or publishes an artifact must use + /// [`Self::function_abi_with`] instead, so that struct parameters carry their components. pub fn function_abi(&self) -> Result { FunctionABI::from_signature(&self.0) } + + /// Returns the ABI representation of the function with struct parameters resolved + pub fn function_abi_with(&self, resolver: &StructResolver) -> Result { + FunctionABI::from_signature_with(&self.0, resolver) + } + pub fn constructor_abi(&self) -> Result { ConstructorABI::from_signature(&self.0) } + /// Returns the constructor ABI with struct parameters resolved + pub fn constructor_abi_with( + &self, + resolver: &StructResolver, + ) -> Result { + ConstructorABI::from_signature_with(&self.0, resolver) + } + pub fn is_fallback(&self) -> bool { self.0.ident == "fallback" } diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__client__tests__generate_client.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__client__tests__generate_client.snap index 00d6a6716..267458042 100644 --- a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__client__tests__generate_client.snap +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__client__tests__generate_client.snap @@ -163,7 +163,6 @@ impl TestContractClient { pub fn first_method( &mut self, contract_address: fluentbase_sdk::Address, - value: fluentbase_sdk::U256, gas_limit: u64, value: u32, ) -> u32 { @@ -171,14 +170,11 @@ impl TestContractClient { let input = fluentbase_sdk::Bytes::from(FirstMethodCall::new((value,)).encode()); { let context = self.sdk.context(); - if context.tx_value() < value { - ::core::panic!("Insufficient funds for transaction"); - } if context.tx_gas_limit() < gas_limit { ::core::panic!("Insufficient gas limit for transaction"); } } - let result = self.sdk.call(contract_address, value, &input, Some(gas_limit)); + let result = self.sdk.static_call(contract_address, &input, Some(gas_limit)); if !fluentbase_sdk::SyscallResult::is_ok(result.status) { ::core::panic!("Contract call failed"); } @@ -187,7 +183,6 @@ impl TestContractClient { pub fn second_method( &mut self, contract_address: fluentbase_sdk::Address, - value: fluentbase_sdk::U256, gas_limit: u64, a: String, b: bool, @@ -196,14 +191,11 @@ impl TestContractClient { let input = fluentbase_sdk::Bytes::from(SecondMethodCall::new((a, b)).encode()); { let context = self.sdk.context(); - if context.tx_value() < value { - ::core::panic!("Insufficient funds for transaction"); - } if context.tx_gas_limit() < gas_limit { ::core::panic!("Insufficient gas limit for transaction"); } } - let result = self.sdk.call(contract_address, value, &input, Some(gas_limit)); + let result = self.sdk.static_call(contract_address, &input, Some(gas_limit)); if !fluentbase_sdk::SyscallResult::is_ok(result.status) { ::core::panic!("Contract call failed"); } diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__all_indexed.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__all_indexed.snap index 46fd69b35..9345073d3 100644 --- a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__all_indexed.snap +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__all_indexed.snap @@ -1,6 +1,5 @@ --- source: crates/sdk-derive/derive-core/src/event.rs -assertion_line: 279 expression: generate(input) --- impl Approval { @@ -17,7 +16,6 @@ impl Approval { &self, sdk: &mut SDK, ) -> Result<(), fluentbase_sdk::ExitCode> { - use fluentbase_sdk::codec::SolidityABI; let topics: [fluentbase_sdk::B256; 4usize] = [ fluentbase_sdk::B256::new([ 140u8, 91u8, 225u8, 229u8, 235u8, 236u8, 125u8, 91u8, 209u8, 79u8, 113u8, @@ -25,39 +23,39 @@ impl Approval { 41u8, 30u8, 91u8, 32u8, 10u8, 200u8, 199u8, 195u8, 185u8, 37u8, ]), { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.owner.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.owner) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.spender.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.spender) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.value.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.value) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, ]; diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__anonymous.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__anonymous.snap index f884806e7..c69939b08 100644 --- a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__anonymous.snap +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__anonymous.snap @@ -1,6 +1,5 @@ --- source: crates/sdk-derive/derive-core/src/event.rs -assertion_line: 308 expression: generate(input) --- impl Anonymous { @@ -17,54 +16,53 @@ impl Anonymous { &self, sdk: &mut SDK, ) -> Result<(), fluentbase_sdk::ExitCode> { - use fluentbase_sdk::codec::SolidityABI; let topics: [fluentbase_sdk::B256; 4usize] = [ { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.a.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.a) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.b.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.b) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.c.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.c) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.d.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.d) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, ]; diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__basic_transfer.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__basic_transfer.snap index fbc843116..4fde08016 100644 --- a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__basic_transfer.snap +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__basic_transfer.snap @@ -1,6 +1,5 @@ --- source: crates/sdk-derive/derive-core/src/event.rs -assertion_line: 264 expression: generate(input) --- impl Transfer { @@ -17,7 +16,6 @@ impl Transfer { &self, sdk: &mut SDK, ) -> Result<(), fluentbase_sdk::ExitCode> { - use fluentbase_sdk::codec::SolidityABI; let topics: [fluentbase_sdk::B256; 3usize] = [ fluentbase_sdk::B256::new([ 221u8, 242u8, 82u8, 173u8, 27u8, 226u8, 200u8, 155u8, 105u8, 194u8, @@ -25,34 +23,35 @@ impl Transfer { 196u8, 161u8, 22u8, 40u8, 245u8, 90u8, 77u8, 245u8, 35u8, 179u8, 239u8, ]), { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.from.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.from) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.to.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.to) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, ]; let data = { let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); let values = (self.value.clone(),); - SolidityABI::encode(&values, &mut buf, 0).expect("encode data fields"); + fluentbase_sdk::codec::SolidityABI::encode_function_args(&values, &mut buf) + .expect("encode data fields"); buf.freeze() }; sdk.emit_log(&topics, &data).ok() diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__dynamic_indexed.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__dynamic_indexed.snap index e141ccfeb..0164142b3 100644 --- a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__dynamic_indexed.snap +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__dynamic_indexed.snap @@ -1,6 +1,5 @@ --- source: crates/sdk-derive/derive-core/src/event.rs -assertion_line: 321 expression: generate(input) --- impl Message { @@ -17,7 +16,6 @@ impl Message { &self, sdk: &mut SDK, ) -> Result<(), fluentbase_sdk::ExitCode> { - use fluentbase_sdk::codec::SolidityABI; let topics: [fluentbase_sdk::B256; 3usize] = [ fluentbase_sdk::B256::new([ 129u8, 31u8, 124u8, 255u8, 10u8, 51u8, 116u8, 255u8, 103u8, 204u8, 204u8, @@ -25,27 +23,27 @@ impl Message { 138u8, 137u8, 30u8, 77u8, 106u8, 204u8, 1u8, 216u8, 142u8, ]), { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.sender.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::
::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.sender) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, { - let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); - let value = self.text.clone(); - SolidityABI::encode(&value, &mut buf, 0).expect("encode indexed field"); - if SolidityABI::::is_dynamic() { - fluentbase_sdk::B256::new(SDK::keccak256(&buf).0) - } else { - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(&buf[..32]); - fluentbase_sdk::B256::new(bytes) + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.text) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } } }, ]; diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__mixed_static_dynamic_data.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__mixed_static_dynamic_data.snap new file mode 100644 index 000000000..31f3e3df1 --- /dev/null +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__mixed_static_dynamic_data.snap @@ -0,0 +1,47 @@ +--- +source: crates/sdk-derive/derive-core/src/event.rs +expression: generate(input) +--- +impl Mixed { + /// Solidity event signature. + pub const SIGNATURE: &'static str = "Mixed(address,uint256,string,address[])"; + /// Keccak256 hash of signature, computed at compile-time. + pub const SELECTOR: [u8; 32] = [ + 104u8, 13u8, 224u8, 66u8, 250u8, 86u8, 81u8, 19u8, 19u8, 71u8, 122u8, 249u8, + 122u8, 144u8, 148u8, 78u8, 215u8, 230u8, 133u8, 208u8, 99u8, 171u8, 242u8, 65u8, + 31u8, 44u8, 221u8, 77u8, 182u8, 181u8, 214u8, 190u8, + ]; + /// Emits this event as an EVM log. + pub fn emit( + &self, + sdk: &mut SDK, + ) -> Result<(), fluentbase_sdk::ExitCode> { + let topics: [fluentbase_sdk::B256; 2usize] = [ + fluentbase_sdk::B256::new([ + 104u8, 13u8, 224u8, 66u8, 250u8, 86u8, 81u8, 19u8, 19u8, 71u8, 122u8, + 249u8, 122u8, 144u8, 148u8, 78u8, 215u8, 230u8, 133u8, 208u8, 99u8, + 171u8, 242u8, 65u8, 31u8, 44u8, 221u8, 77u8, 182u8, 181u8, 214u8, 190u8, + ]), + { + let topic = fluentbase_sdk::codec::encode_indexed_topic(&self.who) + .expect("encode indexed field"); + match topic { + fluentbase_sdk::codec::IndexedTopic::Word(word) => { + fluentbase_sdk::B256::new(word) + } + fluentbase_sdk::codec::IndexedTopic::Preimage(preimage) => { + fluentbase_sdk::B256::new(SDK::keccak256(&preimage).0) + } + } + }, + ]; + let data = { + let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); + let values = (self.amount.clone(), self.note.clone(), self.extra.clone()); + fluentbase_sdk::codec::SolidityABI::encode_function_args(&values, &mut buf) + .expect("encode data fields"); + buf.freeze() + }; + sdk.emit_log(&topics, &data).ok() + } +} diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__no_indexed.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__no_indexed.snap index 4717a51b8..40ef84fcd 100644 --- a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__no_indexed.snap +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__event__tests__no_indexed.snap @@ -1,6 +1,5 @@ --- source: crates/sdk-derive/derive-core/src/event.rs -assertion_line: 290 expression: generate(input) --- impl DataStored { @@ -17,7 +16,6 @@ impl DataStored { &self, sdk: &mut SDK, ) -> Result<(), fluentbase_sdk::ExitCode> { - use fluentbase_sdk::codec::SolidityABI; let topics: [fluentbase_sdk::B256; 1usize] = [ fluentbase_sdk::B256::new([ 211u8, 182u8, 197u8, 148u8, 69u8, 27u8, 234u8, 50u8, 4u8, 113u8, 108u8, @@ -28,7 +26,8 @@ impl DataStored { let data = { let mut buf = fluentbase_sdk::codec::bytes::BytesMut::new(); let values = (self.key.clone(), self.value.clone()); - SolidityABI::encode(&values, &mut buf, 0).expect("encode data fields"); + fluentbase_sdk::codec::SolidityABI::encode_function_args(&values, &mut buf) + .expect("encode data fields"); buf.freeze() }; sdk.emit_log(&topics, &data).ok() diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__sol_input__tests__sol_to_rust_trait_full_surface.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__sol_input__tests__sol_to_rust_trait_full_surface.snap new file mode 100644 index 000000000..1a9e14af9 --- /dev/null +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__sol_input__tests__sol_to_rust_trait_full_surface.snap @@ -0,0 +1,8 @@ +--- +source: crates/sdk-derive/derive-core/src/sol_input.rs +expression: formatted +--- +pub trait IProgram { + fn transfer(&mut self, to: Address, amount: U256) -> bool; + fn balance_of(&self, owner: Address) -> U256; +} diff --git a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__sol_input__tests__sol_to_sol_client_nested_struct.snap b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__sol_input__tests__sol_to_sol_client_nested_struct.snap index 2b0d09a49..ad700330a 100644 --- a/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__sol_input__tests__sol_to_sol_client_nested_struct.snap +++ b/crates/sdk-derive/derive-core/src/snapshots/fluentbase_sdk_derive_core__sol_input__tests__sol_to_sol_client_nested_struct.snap @@ -13,5 +13,6 @@ pub struct Outer { } #[client(mode = "solidity")] pub trait IProgram { + #[state_mutability("view")] fn ping(&self, input: Outer) -> Outer; } diff --git a/crates/sdk-derive/derive-core/src/sol_input.rs b/crates/sdk-derive/derive-core/src/sol_input.rs index d7c3e178d..3a2dd6d8e 100644 --- a/crates/sdk-derive/derive-core/src/sol_input.rs +++ b/crates/sdk-derive/derive-core/src/sol_input.rs @@ -1,4 +1,10 @@ -use crate::abi::types::{convert_solidity_type, sol_to_rust}; +use crate::{ + abi::{ + function::StateMutability, + types::{convert_solidity_type, sol_to_rust}, + }, + attr::{StateMutabilityExt, STATE_MUTABILITY_ATTR}, +}; use alloy_sol_macro_input::{SolInput, SolInputKind}; use convert_case::{Case, Casing}; use proc_macro2::{Span, TokenStream}; @@ -35,7 +41,8 @@ impl<'a> Visit<'a> for Collector<'a> { /// /// A TokenStream representing the generated Rust trait pub fn to_rust_trait(input: SolInput) -> syn::Result { - let (structs, trait_name, trait_fns) = convert_sol_to_rust(input)?; + // A plain trait is compiled as-is, so it must not carry helper attributes. + let (structs, trait_name, trait_fns) = convert_sol_to_rust(input, false)?; // Generate the final output for trait Ok(quote! { @@ -56,7 +63,9 @@ pub fn to_rust_trait(input: SolInput) -> syn::Result { /// /// A TokenStream representing the generated Rust client trait pub fn to_sol_client(input: SolInput) -> syn::Result { - let (structs, trait_name, trait_fns) = convert_sol_to_rust(input)?; + // The `client` macro consumes the trait, so mutability can be carried over + // as an attribute and decide which host call each method issues. + let (structs, trait_name, trait_fns) = convert_sol_to_rust(input, true)?; // Generate the final output for client trait with attribute Ok(quote! { @@ -73,12 +82,15 @@ pub fn to_sol_client(input: SolInput) -> syn::Result { /// # Arguments /// /// * `input` - The Solidity input to convert +/// * `emit_mutability` - Whether to annotate methods with their Solidity state +/// mutability, which is only valid for traits consumed by a macro /// /// # Returns /// /// A tuple of (structs, trait_name, trait_methods) to be assembled fn convert_sol_to_rust( input: SolInput, + emit_mutability: bool, ) -> syn::Result<(Vec, Ident, Vec)> { // Get the Solidity file from the input let file = match input.kind { @@ -108,16 +120,41 @@ fn convert_sol_to_rust( .map(|s| sol_struct_to_rust_tokens(s)) .collect::>>()?; - let trait_fns = visitor - .functions - .iter() - .filter_map(|func| sol_fn_to_trait_method(func).ok()) - .filter(|tokens| !tokens.is_empty()) - .collect::>(); + // Every function must convert: dropping one would silently shrink the interface surface. + let mut trait_fns = Vec::new(); + let mut errors = Vec::new(); + for func in &visitor.functions { + match sol_fn_to_trait_method(func, emit_mutability) { + Ok(tokens) if tokens.is_empty() => {} + Ok(tokens) => trait_fns.push(tokens), + Err(err) => errors.push(err), + } + } + + if let Some(err) = combine_errors(errors) { + return Err(err); + } Ok((structs, trait_name, trait_fns)) } +/// Merges accumulated errors into a single one so a build reports every +/// unsupported item at once instead of only the first +/// +/// # Arguments +/// +/// * `errors` - The collected conversion errors +/// +/// # Returns +/// +/// The merged error, or `None` if there were no errors +fn combine_errors(errors: Vec) -> Option { + errors.into_iter().reduce(|mut acc, err| { + acc.combine(err); + acc + }) +} + /// Derives a trait name from the Solidity file /// /// # Arguments @@ -161,9 +198,33 @@ fn derive_trait_name(file: &File) -> syn::Result { /// /// A TokenStream representing the receiver (&self or &mut self) fn determine_method_receiver(func: &ItemFunction) -> TokenStream { + if sol_state_mutability(func).is_static() { + quote! { &self } + } else { + quote! { &mut self } + } +} + +/// Reads the state mutability of a Solidity function. +/// +/// It is carried over to the generated trait so client generation keeps issuing +/// the host call the Solidity declaration asks for, instead of defaulting every +/// method to a mutable `CALL` with a value. +/// +/// # Arguments +/// +/// * `func` - The Solidity function +/// +/// # Returns +/// +/// The state mutability of the function +fn sol_state_mutability(func: &ItemFunction) -> StateMutability { match func.attributes.mutability() { - Some(Mutability::View(_)) | Some(Mutability::Pure(_)) => quote! { &self }, - _ => quote! { &mut self }, + Some(Mutability::Pure(_)) => StateMutability::Pure, + // `constant` is the legacy spelling of `view` + Some(Mutability::View(_) | Mutability::Constant(_)) => StateMutability::View, + Some(Mutability::Payable(_)) => StateMutability::Payable, + None => StateMutability::NonPayable, } } @@ -192,7 +253,9 @@ fn sol_struct_to_rust_tokens(sol_struct: &ItemStruct) -> syn::Result syn::Result syn::Result { +fn sol_fn_to_trait_method(func: &ItemFunction, emit_mutability: bool) -> syn::Result { // Skip functions without a name or special functions let Some(name) = &func.name else { return Ok(quote! {}); @@ -233,19 +298,41 @@ fn sol_fn_to_trait_method(func: &ItemFunction) -> syn::Result { let fn_name = format_ident!("{}", name.to_string().to_case(Case::Snake)); let receiver = determine_method_receiver(func); - // Generate function parameters - let args = func - .parameters - .iter() - .enumerate() - .filter_map(|(i, param)| sol_param_to_tokens(i, param).ok()) - .collect::>(); + // Generate function parameters. A dropped parameter would change the selector, + // so any failure has to abort the whole function. + let mut args = Vec::new(); + let mut errors = Vec::new(); + for (i, param) in func.parameters.iter().enumerate() { + match sol_param_to_tokens(i, param) { + Ok(tokens) => args.push(tokens), + Err(err) => errors.push(err), + } + } // Generate function return type - let ret = sol_return_to_tokens(func)?; + let ret = match sol_return_to_tokens(func) { + Ok(tokens) => tokens, + Err(err) => { + errors.push(err); + quote! {} + } + }; + + if let Some(err) = combine_errors(errors) { + return Err(err); + } + + let mutability_attr = if emit_mutability { + let mutability = sol_state_mutability(func).as_str(); + let attr = format_ident!("{}", STATE_MUTABILITY_ATTR); + quote! { #[#attr(#mutability)] } + } else { + quote! {} + }; // Generate the function signature Ok(quote! { + #mutability_attr fn #fn_name(#receiver #(, #args)*) #ret; }) } @@ -271,7 +358,8 @@ fn sol_param_to_tokens(index: usize, param: &VariableDeclaration) -> syn::Result let name_ident = format_ident!("{}", name_str); // Convert Solidity type to Rust type - let sol_ty = convert_solidity_type(¶m.ty)?; + let sol_ty = convert_solidity_type(¶m.ty) + .map_err(|e| syn::Error::new(param.ty.span(), format!("Cannot convert param type: {e}")))?; let rust_ty = sol_to_rust(&sol_ty) .map_err(|e| syn::Error::new(param.ty.span(), format!("Cannot convert param type: {e}")))?; @@ -297,27 +385,32 @@ fn sol_return_to_tokens(func: &ItemFunction) -> syn::Result { // If there's only one return parameter, use it directly if return_params.len() == 1 { - let sol_ty = convert_solidity_type(&return_params[0].ty)?; - let rust_ty = sol_to_rust(&sol_ty).map_err(|e| { - syn::Error::new( - return_params[0].ty.span(), - format!("Return type error: {e}"), - ) - })?; + let span = return_params[0].ty.span(); + let sol_ty = convert_solidity_type(&return_params[0].ty) + .map_err(|e| syn::Error::new(span, format!("Return type error: {e}")))?; + let rust_ty = sol_to_rust(&sol_ty) + .map_err(|e| syn::Error::new(span, format!("Return type error: {e}")))?; return Ok(quote! { -> #rust_ty }); } // For multiple return parameters, create a tuple - let rust_types = return_params - .iter() - .map(|param| { - let sol_ty = convert_solidity_type(¶m.ty)?; - sol_to_rust(&sol_ty).map(|ty| quote! { #ty }).map_err(|e| { - syn::Error::new(param.ty.span(), format!("Tuple return type error: {e}")) - }) - }) - .collect::>>()?; + let mut rust_types = Vec::new(); + let mut errors = Vec::new(); + for param in return_params.iter() { + let converted = convert_solidity_type(¶m.ty) + .and_then(|sol_ty| sol_to_rust(&sol_ty)) + .map_err(|e| syn::Error::new(param.ty.span(), format!("Tuple return type error: {e}"))); + + match converted { + Ok(ty) => rust_types.push(quote! { #ty }), + Err(err) => errors.push(err), + } + } + + if let Some(err) = combine_errors(errors) { + return Err(err); + } Ok(quote! { -> (#(#rust_types),*) }) } @@ -396,6 +489,72 @@ library SomeLibrary { assert_snapshot!("sol_to_rust_trait_nested_struct", formatted); } + #[test] + fn test_unsupported_param_type_fails() { + let solidity_code = r#" + interface IProgram { + function transfer(address to, function() external cb) external; + function ping() external view returns (uint256); + } + "#; + let input: alloy_sol_macro_input::SolInput = parse_str(solidity_code).unwrap(); + + let err = to_rust_trait(input).unwrap_err(); + assert!( + err.to_string().contains("Cannot convert param type"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_unsupported_return_type_fails() { + let solidity_code = r#" + interface IProgram { + function lookup() external view returns (function() external); + } + "#; + let input: alloy_sol_macro_input::SolInput = parse_str(solidity_code).unwrap(); + + let err = to_rust_trait(input).unwrap_err(); + assert!( + err.to_string().contains("Return type error"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_every_unsupported_item_is_reported() { + let solidity_code = r#" + interface IProgram { + function first(function() external cb) external; + function second(function() external cb) external; + } + "#; + let input: alloy_sol_macro_input::SolInput = parse_str(solidity_code).unwrap(); + + let err = to_rust_trait(input).unwrap_err(); + assert_eq!(err.into_iter().count(), 2); + } + + #[test] + fn test_supported_interface_keeps_all_functions_and_params() { + let solidity_code = r#" + interface IProgram { + function transfer(address to, uint256 amount) external returns (bool); + function balanceOf(address owner) external view returns (uint256); + fallback() external; + receive() external payable; + } + "#; + let input: alloy_sol_macro_input::SolInput = parse_str(solidity_code).unwrap(); + + let generated = to_rust_trait(input).unwrap(); + let parsed = syn::parse_file(&generated.to_string()).unwrap(); + let formatted = prettyplease::unparse(&parsed); + + assert_snapshot!("sol_to_rust_trait_full_surface", formatted); + } + #[test] fn test_to_sol_client_nested_struct() { let solidity_code = r#" @@ -423,4 +582,84 @@ library SomeLibrary { assert_snapshot!("sol_to_sol_client_nested_struct", formatted); } + + /// Generates the client of a Solidity interface and strips whitespace, so + /// assertions can pin the exact host call without depending on formatting + fn sol_client_source(solidity_code: &str) -> String { + let input: alloy_sol_macro_input::SolInput = parse_str(solidity_code).unwrap(); + + // The trait produced here is what the `client` macro receives + let trait_def: syn::ItemTrait = syn::parse2(to_sol_client(input).unwrap()).unwrap(); + let client = crate::client::Client::new( + Default::default(), + trait_def, + &crate::abi::structs::StructResolver::default(), + ) + .unwrap(); + + client + .generate() + .unwrap() + .to_string() + .chars() + .filter(|c| !c.is_whitespace()) + .collect() + } + + #[test] + fn test_solidity_mutability_decides_the_host_call() { + let generated = sol_client_source( + r#" + interface IProgram { + function answer() external pure returns (uint256); + function balance() external view returns (uint256); + function reset() external; + function deposit() external payable; + } + "#, + ); + + // `pure` and `view` cannot mutate state, so they must be static calls + // and must not be able to attach a value + assert!(generated.contains( + "pubfnanswer(&mutself,contract_address:fluentbase_sdk::Address,gas_limit:u64,)" + )); + assert!(generated.contains( + "pubfnbalance(&mutself,contract_address:fluentbase_sdk::Address,gas_limit:u64,)" + )); + assert_eq!( + generated + .matches("self.sdk.static_call(contract_address,&input,Some(gas_limit),)") + .count(), + 2 + ); + + // `nonpayable` mutates but rejects value, `payable` forwards it + assert!(generated.contains( + "pubfnreset(&mutself,contract_address:fluentbase_sdk::Address,gas_limit:u64,)" + )); + assert!(generated.contains( + "self.sdk.call(contract_address,fluentbase_sdk::U256::ZERO,&input,Some(gas_limit),)" + )); + assert!(generated.contains( + "pubfndeposit(&mutself,contract_address:fluentbase_sdk::Address,value:fluentbase_sdk::U256,gas_limit:u64,)" + )); + assert!(generated.contains("self.sdk.call(contract_address,value,&input,Some(gas_limit),)")); + } + + #[test] + fn test_legacy_constant_functions_are_read_only() { + let generated = sol_client_source( + r#" + interface IProgram { + function balance() external constant returns (uint256); + } + "#, + ); + + assert!( + generated.contains("self.sdk.static_call(contract_address,&input,Some(gas_limit),)") + ); + assert!(!generated.contains("self.sdk.call(")); + } } diff --git a/crates/sdk-derive/docs/client.md b/crates/sdk-derive/docs/client.md index fb8787991..425aa64b0 100644 --- a/crates/sdk-derive/docs/client.md +++ b/crates/sdk-derive/docs/client.md @@ -65,7 +65,8 @@ impl ERC20Client { amount: U256, gas_limit: u64 ) -> bool { - let balance = self.balance_of(contract_address, U256::zero(), gas_limit, to); + // `balance_of` takes `&self`, so it is a static call without a value + let balance = self.balance_of(contract_address, gas_limit, to); if balance >= amount { self.transfer(contract_address, U256::zero(), gas_limit, to, amount) } else { @@ -96,11 +97,12 @@ trait Governance { ## Notes & Best Practices -- **Automatic Client Methods**: For each trait method, the macro generates a client method that takes contract address, value, gas limit, and function parameters +- **Automatic Client Methods**: For each trait method, the macro generates a client method that takes contract address, value (payable methods only), gas limit, and function parameters - **Return Types**: Return types are automatically decoded from contract call results - **Error Handling**: Client methods panic if the contract call fails (for simplicity - you may want to extend with custom error handling) - **SDK Requirement**: The generated client requires an SDK type that implements `fluentbase_sdk::SharedAPI` - **Trait Methods**: Only trait methods are included in the client (not custom implementations) -- **Method Receivers**: The macro respects method receivers - `&self` generates a read-only call, `&mut self` allows value transfer +- **Method Receivers**: The macro respects method receivers - `&self` generates a `STATICCALL` with no value parameter, `&mut self` generates a `CALL` that forwards a value +- **State Mutability**: `#[state_mutability("pure" | "view" | "nonpayable" | "payable")]` overrides that default. It is emitted automatically by `derive_solidity_client`, so a Solidity `nonpayable` function keeps its mutable call but loses the value parameter - **ABI Compatibility**: Use the same encoding mode (`solidity` or `fluent`) as the contract you're calling - **Type Consistency**: Ensure parameter and return types match between client and contract for proper encoding/decoding diff --git a/crates/sdk-derive/docs/solidity_client.md b/crates/sdk-derive/docs/solidity_client.md index d343cfb70..5435b018c 100644 --- a/crates/sdk-derive/docs/solidity_client.md +++ b/crates/sdk-derive/docs/solidity_client.md @@ -41,6 +41,7 @@ derive_solidity_client!( function totalSupply() external view returns (uint256); function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); + function deposit() external payable; } ); @@ -48,18 +49,20 @@ derive_solidity_client!( fn interact_with_erc20(sdk: SDK, token_address: Address) { let mut client = IERC20Client::new(sdk); - // Call the contract methods - let total = client.total_supply(token_address, U256::zero(), 100000); - let balance = client.balance_of(token_address, U256::zero(), 100000, my_address); + // `view` and `pure` functions are issued as static calls and take no value + let total = client.total_supply(token_address, 100000); + let balance = client.balance_of(token_address, 100000, my_address); - // Send tokens + // `nonpayable` functions are mutable calls, but cannot carry a value let success = client.transfer( token_address, - U256::zero(), // No value sent with the call - 100000, // Gas limit - recipient, // To address - U256::from(100) // Amount to transfer + 100000, // Gas limit + recipient, // To address + U256::from(100) // Amount to transfer ); + + // Only `payable` functions accept a value + client.deposit(token_address, U256::from(1), 100000); } ``` @@ -74,17 +77,22 @@ derive_solidity_client!("abi/IToken.sol"); // The client is generated automatically // You can use it like this: let mut client = ITokenClient::new(sdk); -let result = client.method_name(contract_address, value, gas_limit, ...args); +let result = client.method_name(contract_address, gas_limit, ...args); ``` ## Notes & Best Practices - **Parameter Order**: Generated client methods take standard parameters first: - `contract_address`: The address of the contract to call - - `value`: Amount of native tokens to send with the call (usually `U256::zero()`) + - `value`: Amount of native tokens to send with the call, only present for `payable` functions - `gas_limit`: Maximum gas for the transaction - Then any function-specific parameters +- **State Mutability**: The Solidity mutability decides which host call is issued: + - `pure` and `view` become `STATICCALL`, so a callee cannot mutate state through them + - `nonpayable` becomes `CALL` with a zero value + - `payable` becomes `CALL` with the caller-supplied value + - **Client Structure**: The generated client follows the same patterns as the [`client` macro](client.md) - **Trait Generation**: The macro first generates a trait (as `derive_solidity_trait` would), then applies `#[client(mode = "solidity")]` to it diff --git a/crates/sdk-derive/src/lib.rs b/crates/sdk-derive/src/lib.rs index a5f9229f2..10cde431f 100644 --- a/crates/sdk-derive/src/lib.rs +++ b/crates/sdk-derive/src/lib.rs @@ -136,7 +136,6 @@ pub fn router(attr: TokenStream, input: TokenStream) -> TokenStream { /// // Call contract methods with standard parameters /// let balance = client.balance_of( /// token_address, // contract address -/// U256::zero(), // value to send (none) /// 50000, // gas limit /// my_address // method-specific parameters /// ); @@ -149,8 +148,8 @@ pub fn router(attr: TokenStream, input: TokenStream) -> TokenStream { /// - `TokenInterfaceClient` struct with a `new(sdk)` constructor /// - Method implementations that append common parameters: ```rust,ignore fn method_name( &mut /// self, contract_address: Address, // Target contract value: U256, // Native -/// tokens to send gas_limit: u64, // Maximum gas ...original_parameters // From -/// trait definition ) -> original_return_type ``` +/// tokens to send, payable methods only gas_limit: u64, // Maximum gas +/// ...original_parameters // From trait definition ) -> original_return_type ``` /// /// # Features /// @@ -158,6 +157,9 @@ pub fn router(attr: TokenStream, input: TokenStream) -> TokenStream { /// - **Runtime safety checks** for insufficient funds or gas /// - **Compatible with router** when using the same encoding mode /// - **Preserves method signatures** from the trait definition +/// - **Preserves state mutability**: `&self` (or `#[state_mutability("pure"/"view")]`) issues a +/// `STATICCALL` and takes no value, `#[state_mutability("nonpayable")]` calls with a zero value, +/// and only payable methods forward a caller-supplied value /// /// # Attributes /// diff --git a/crates/sdk/src/storage/map.rs b/crates/sdk/src/storage/map.rs index 709411c4c..edd568c85 100644 --- a/crates/sdk/src/storage/map.rs +++ b/crates/sdk/src/storage/map.rs @@ -1,5 +1,5 @@ use crate::{ - storage::{PackableCodec, StorageDescriptor, StorageLayout}, + storage::{StorageDescriptor, StorageLayout}, U256, }; use alloc::{string::String, vec::Vec}; @@ -86,20 +86,35 @@ pub trait MapKey { fn compute_slot(&self, base_slot: U256) -> U256; } -// MapKey for primitive types via PackableCodec -impl MapKey for T { - fn compute_slot(&self, base_slot: U256) -> U256 { - let mut key_bytes = [0u8; 32]; - - // Right-align key in 32 bytes - if T::ENCODED_SIZE <= 32 { - let offset = 32 - T::ENCODED_SIZE; - self.encode_into(&mut key_bytes[offset..]); - } +/// Solidity's `h(k)`: a fixed-size mapping key padded to one 32-byte word. +/// +/// Solidity locates `mapping(K => V)` entries at `keccak256(h(k) . p)`, where `h` +/// pads the key the same way the key type is laid out in memory. That is *not* the +/// packed storage layout of [`PackableCodec`], which right-aligns everything: +/// +/// | key type | `h(k)` | +/// |----------------------------|------------------------------------------| +/// | `uintN`, `address`, `bool` | right-aligned, zero-padded on the left | +/// | `intN` | right-aligned, sign-extended on the left | +/// | `bytesN` | left-aligned, zero-padded on the right | +/// +/// Reusing one universal layout would place negative `intN` and every `bytesN` +/// key on a different slot than Solidity, splitting state across mixed-language, +/// state-proof, and Solidity-to-rWasm upgrade boundaries. So key padding is its +/// own trait, and every key type states its alignment explicitly. +/// +/// Dynamic keys (`bytes`, `string`) are hashed unpadded and implement [`MapKey`] +/// directly instead. +pub trait MapKeyCodec: Copy { + /// Encode the key as Solidity would pad it into a 32-byte word. + fn encode_key_word(&self) -> [u8; 32]; +} - // keccak256(key || base_slot) +impl MapKey for T { + fn compute_slot(&self, base_slot: U256) -> U256 { + // keccak256(h(key) || base_slot) let mut data = [0u8; 64]; - data[0..32].copy_from_slice(&key_bytes); + data[0..32].copy_from_slice(&self.encode_key_word()); data[32..64].copy_from_slice(&base_slot.to_be_bytes::<32>()); let hash = crypto_keccak256(data); @@ -445,6 +460,552 @@ mod tests { assert_eq!(sdk.get_slot(slot2), U256::from(222)); } + /// Mapping-key slots cross-checked against the Solidity compiler. + /// + /// Every expected slot below was produced by solc 0.8.34, not by this + /// implementation. Each key type is declared in a contract whose slot 0 is an + /// unused padding word, so `mapping(intN => uint256)` and + /// `mapping(uintN => uint256)` sit at base slot `N / 8` and + /// `mapping(bytesN => uint256)` at base slot `N`. A separate contract holds + /// `address` at slot 1, `bool` at 2, + /// `mapping(address => mapping(address => uint256))` at 3, + /// `mapping(int24 => mapping(bytes4 => uint256))` at 4, `string` at 5 and + /// `bytes` at 6. A forge test calls each compiler-generated getter under + /// `vm.record()` and reports the slot the compiled code actually loads. + mod solidity_vectors { + use super::*; + use crate::{hex, storage::StoragePrimitive, Address, FixedBytes, Signed, Uint}; + + /// `bytesN` vectors use the first `N` bytes of this pattern as the key. + const PATTERN: [u8; 32] = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, + 0xdd, 0xee, 0xff, 0x01, + ]; + + /// `0x00112233445566778899aabbccddeeff00112233` + const ADDR_A: [u8; 20] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, + ]; + + /// `0xffeeddccbbaa99887766554433221100ffeeddcc` + const ADDR_B: [u8; 20] = [ + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, + 0x11, 0x00, 0xff, 0xee, 0xdd, 0xcc, + ]; + + /// Parses a reference slot recorded from Solidity. + fn expect(slot_hex: &str) -> U256 { + let bytes = hex::decode(slot_hex.strip_prefix("0x").unwrap()).expect("invalid vector"); + U256::from_be_bytes::<32>(bytes.try_into().expect("vector must be 32 bytes")) + } + + macro_rules! check_signed { + ($($bits:literal, $limbs:literal => + minus_one: $minus_one:literal, + min: $min:literal, + one: $one:literal;)*) => { + $({ + let base = U256::from($bits / 8); + assert_eq!( + Signed::<$bits, $limbs>::MINUS_ONE.compute_slot(base), + expect($minus_one), + "int{} key -1", + $bits, + ); + assert_eq!( + Signed::<$bits, $limbs>::MIN.compute_slot(base), + expect($min), + "int{} key MIN", + $bits, + ); + assert_eq!( + Signed::<$bits, $limbs>::ONE.compute_slot(base), + expect($one), + "int{} key 1", + $bits, + ); + })* + }; + } + + macro_rules! check_unsigned { + ($($bits:literal, $limbs:literal => + one: $one:literal, + max: $max:literal;)*) => { + $({ + let base = U256::from($bits / 8); + assert_eq!( + Uint::<$bits, $limbs>::ONE.compute_slot(base), + expect($one), + "uint{} key 1", + $bits, + ); + assert_eq!( + Uint::<$bits, $limbs>::MAX.compute_slot(base), + expect($max), + "uint{} key MAX", + $bits, + ); + })* + }; + } + + macro_rules! check_fixed_bytes { + ($($n:literal => $slot:literal;)*) => { + $({ + let key = FixedBytes::<$n>::from_slice(&PATTERN[..$n]); + assert_eq!( + key.compute_slot(U256::from($n)), + expect($slot), + "bytes{} key", + $n, + ); + })* + }; + } + + #[test] + fn signed_keys_are_sign_extended_like_solidity() { + check_signed! { + 8, 1 => + minus_one: "0xc39d774f18115b85b81494d65e588b565d73abc969333d1da7b0a0eb0729accd", + min: "0xa6448894d065a3e7161c6293c2db5e883893c02ad215165c869901858b9036a9", + one: "0xcc69885fda6bcc1a4ace058b4a62bf5e179ea78fd58a1ccd71c22cc9b688792f"; + 16, 1 => + minus_one: "0x38b5b2ceac7637132d27514ffcf440b705287635075af7b8bd5adcaa6a4cc5bb", + min: "0xcf2fb756914b19221bf2fef06148e4b4b9392b9c9a1f0bc7d93709ef28342ba5", + one: "0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0"; + 24, 1 => + minus_one: "0xb1ee3b3d0d99532dd9f14b22c0b908d4eec0e052c3827bbed2d6c3986954d08c", + min: "0x7f43945837635d09673421abd13cee36348137796c27cd8bd12ca4525a0df5e2", + one: "0xa15bc60c955c405d20d9149c709e2460f1c2d9a497496a7f46004d1772c3054c"; + 32, 1 => + minus_one: "0xd8c80a9840ed58f33f2186a8fbc29ecd8c3610d196f1da047301bd51988eb95c", + min: "0x6e55b48d4744edeccafa886884bc9a4dbd00a777c2dcd5791e8b167088622824", + one: "0xabd6e7cb50984ff9c2f3e18a2660c3353dadf4e3291deeb275dae2cd1e44fe05"; + 40, 1 => + minus_one: "0x2e8de2577e7c560a9913fd732cd5ba1f61f809b10c283800da9499091ac562a5", + min: "0x57a63a1072d76f72468c74cfd819530d2f5b24d1e3e247400e5ccfa7d3bd936f", + one: "0x1471eb6eb2c5e789fc3de43f8ce62938c7d1836ec861730447e2ada8fd81017b"; + 48, 1 => + minus_one: "0x63187d71e139eee983a88d0737447c7451979b3dbb75903c76b5fe430d36588e", + min: "0x135966d89c951315b28d6b29b104e8511bea5a257c171d75feae6da900c60615", + one: "0x3e5fec24aa4dc4e5aee2e025e51e1392c72a2500577559fae9665c6d52bd6a31"; + 56, 1 => + minus_one: "0xa79741ff9376312d805b646fe98c5eaaa690c33d0a4c18cb1d87dfa9e9a9af0b", + min: "0x6c08c200821b9f7623e3b9bfd71a73518d5dfc013abeb7aaccb7895bb3a93795", + one: "0xb39221ace053465ec3453ce2b36430bd138b997ecea25c1043da0c366812b828"; + 64, 1 => + minus_one: "0x50015d2c5500ee864adc0ae35838917a2d9a98eb2ab97342b4d689d9a074dbfa", + min: "0xb6f2a08fd84fd72638abdb192e8c8e97b6d535d5052c4a0421762dcd1a7379cb", + one: "0xad67d757c34507f157cacfa2e3153e9f260a2244f30428821be7be64587ac55f"; + 72, 2 => + minus_one: "0x8be17021fa7918486222bbb1bc9d45bbdf93d7f49d8066170141bb3a10b823f4", + min: "0x5142a8cc62fa4971670907ac3dc698d9c16246254da7df61811d43a2bbbea5ca", + one: "0x92e85d02570a8092d09a6e3a57665bc3815a2699a4074001bf1ccabf660f5a36"; + 80, 2 => + minus_one: "0x1cde448d8c1d4666ae6874ced948f1e0ad12a4bed8302f3be564e5fd7540b8eb", + min: "0x86101a743af0d237ba40bd3fdf92ca810b6afc8d27f17d39fa77dfcea754b088", + one: "0xbbc70db1b6c7afd11e79c0fb0051300458f1a3acb8ee9789d9b6b26c61ad9bc7"; + 88, 2 => + minus_one: "0x5030e2ff9d3404671c17fc42c5714d9bdf34dbce2663e34014ba8c942df513fb", + min: "0xd62a59a1c880f5e3c9691388aac3294aa9a50a92199bf7abb89c7ac98b6b3cb3", + one: "0x72c6bfb7988af3a1efa6568f02a999bc52252641c659d85961ca3d372b57d5cf"; + 96, 2 => + minus_one: "0xa10f30cc98fff5708c93df4a220b8f117034e197ce203f377237571fbe641ae7", + min: "0xabd93e15c426f9233215b50d1fb0238f25cebf5780846a88e6625af42059adb9", + one: "0xd421a5181c571bba3f01190c922c3b2a896fc1d84e86c9f17ac10e67ebef8b5c"; + 104, 2 => + minus_one: "0xc84cd90342df8739b373f7be527807c208a84569afc12be2cb8f5c5052dfb349", + min: "0x5625d07e9a3b7082acb0a43ba1bef59b763540dd5536b76a44ddd0f6fd0c1268", + one: "0xfd54ff1ed53f34a900b24c5ba64f85761163b5d82d98a47b9bd80e45466993c5"; + 112, 2 => + minus_one: "0x486cf3b7204a0f1112420044e95a29440d09fee9d3a9392854ddf6d046c953b3", + min: "0x757bc3c4a1c49a515fa968225656a81b6079a27f10075e8471571b79a5166c53", + one: "0xa7c5ba7114a813b50159add3a36832908dc83db71d0b9a24c2ad0f83be958207"; + 120, 2 => + minus_one: "0x5f49b1b339959b31ad74f07b88b44259ea11bfb77e53c425ee4e04612cace647", + min: "0xd92dd9dcf7ded059da2f95bceb2d17f1316abce458cb99f3a8de1a7a3559efd5", + one: "0x169f97de0d9a84d840042b17d3c6b9638b3d6fd9024c9eb0c7a306a17b49f88f"; + 128, 2 => + minus_one: "0x67c618532631f5e38a3b9c5e06a3e5553e5fac44409c6f6f7364f2525b56773a", + min: "0x5a065330214f8c8ac10a99d46ab28c8765aec8bb0237b9ab0d1086283ca05320", + one: "0x8c6065603763fec3f5742441d3833f3f43b982453612d76adb39a885e3006b5f"; + 136, 3 => + minus_one: "0xa69f8edf3b946707c160fd4b4533bf1626bacaec5eefa8c07ba416ea6a23adb0", + min: "0x361bd2d8a4a72401470b71335f24667611e4070a53206b822f57ab640f4800b0", + one: "0x17bc176d2408558f6e4111feebc3cab4e16b63e967be91cde721f4c8a488b552"; + 144, 3 => + minus_one: "0x38a7014c891815c312752673b617180a4abd1a642674354e7719ef6c24c13037", + min: "0x8afb98d9b835ce67313f66fa35cb4ff343d480b500520615b8a222babf9b0ee3", + one: "0x71a67924699a20698523213e55fe499d539379d7769cd5567e2c45d583f815a3"; + 152, 3 => + minus_one: "0x84b7e90e34a243706436e6c933eda22efd83d670717692a842a132fb5d4f8d7c", + min: "0x7877698bca6fb5ad0219127ce4b6365a8aaa7d1c97fa9f00cd0fd824eb188e85", + one: "0x4155c2f711f2cdd34f8262ab8fb9b7020a700fe7b6948222152f7670d1fdf34d"; + 160, 3 => + minus_one: "0x435ceb1ad05f2d2be0f6b7fbfab2b8d011eee8ed51a9b37c03338442a93ceef6", + min: "0xc83f523ca9f6ea8e8bf8c79a44a16be4798489c266110ad6839f7d2367b31d93", + one: "0xb6c61a840592cc84133e4b25bd509abf4659307c57b160799b38490a5aa48f2c"; + 168, 3 => + minus_one: "0x9f0c13364c3666fa97786132804bacda0065140b4d45b1f05fcdaffca8e4c926", + min: "0x4b4e0a10ba1fe0cc821b7e96b3b0de28068b5d977d1f7ceeaf9e66b2647ffee7", + one: "0x27739e4bb5e6f8b5e4b57a047dca8767cc9b982a011081e086cbb0dfa9de818d"; + 176, 3 => + minus_one: "0x7c383b0a2168581fe8dfa3f696746b1d84f1675fb4959108456d60162848933c", + min: "0x386ec75a78bdad7adec7c75deb87238f88b2d71cae09b8b59dd7e7949c847a3b", + one: "0x4c4dc693d7db52f85fe052106f4b4b920e78e8ef37dee82878a60ab8585faf49"; + 184, 3 => + minus_one: "0x6488e0c85a2670bdd10614c45b24da372bbfe3fc4b4ecffa8b7d65945a3f7e33", + min: "0xe0439e8fbce049c687ff15271196b85d5f92997efe5040d090ce16358a7c6ce6", + one: "0xf36d6bc9642eb6fb6ee9998b09ce990566df752ab06e11f8de7ab633bbd57b8f"; + 192, 3 => + minus_one: "0x399d0a24b148009d3f8925693da45cc0775b49707d17362b0daea06e36536938", + min: "0xbec190a481d6fd1937a0e956d8411f1910c2e548faf2fe353385c2bdf5ded5e1", + one: "0xf3794665d3af9b6fb6f858b70185898134f96768ef31c325d52e04f0ac195a4d"; + 200, 4 => + minus_one: "0x0d7f73ba8afef39cfc7064e55120c450f3d9a24fe3988ad6d7c5309656095f0a", + min: "0xb9a59b292632ae7335a7f493f0a4646384cd1fde8da16a9603c32dee11ab93b7", + one: "0xfc941c3961fb6541da34150022cddf959da0fb2353866a6bfbd249c2da092914"; + 208, 4 => + minus_one: "0x327c4029158af2da36a42cb8f96d218a24f8825507f9c7cab6ed11be5135155e", + min: "0x0b033076351988b1f7d2a8891d7a749fc8ef53714348669eb2ce7b786bf9e1de", + one: "0xf88cd8d612926ebb404e40725c01084b6e9b3ce0344cde068570342cbd448c61"; + 216, 4 => + minus_one: "0xaaf94f6ea6d0e15c9806381e0c5077d02aa78581851e36902a30ccea61d9f2fb", + min: "0x05c76157f46a731299afa0609d0eb68b3f7c89f55e9aef10f4b4fb28fc1ad2bd", + one: "0x9fafca4c9c0d5c2cbf85f49fd8ab8212430ce78c2a0cb75b51e0f9c4f9ace003"; + 224, 4 => + minus_one: "0x9a12beb065eada6943065a3ff2e4e903c520ca533ba60d47c7b9c3f800bd750c", + min: "0x1f129cec89a15894e0a64c14c4872f900cc574731ead4454e2ce836039fc9df3", + one: "0x6de76108811faf2f94afbe5ac6c98e8393206cd093932de1fbfd61bbeec43a02"; + 232, 4 => + minus_one: "0xc736243efc8b4b64d465443e7c5628e1e24fbb99ca2384861dddb1ebbe7e8267", + min: "0x8416e4d1a17e9fbba75b05ce6d213263484afec3bb6facabfad9cd34a7587631", + one: "0x9de6abd965d55c3bb0cdbf6fa175050624c6ff8fe86f682dc08f2a450ede2278"; + 240, 4 => + minus_one: "0xfd813bec61c7c0cea56a4053999cb1b40a70c64e6e3060d7978d40d9ef4991bb", + min: "0x7e5f2308925f19ef1c3bb4e1e1ad1149ceb9e6aa82698ab7112d48d60d3120df", + one: "0x873299c6a6c39b8b92f01922bb622df4a3236ea2876aac2da76f6c092cf7e98f"; + 248, 4 => + minus_one: "0x719ec84beffbf4a755745bc9a7e34eb5cf8ed94f6924a2e33811d832d938b4b5", + min: "0x1410eb8899aa298a78b63eb9c4c588a952b59d0f4867cda76cd24b0423305c26", + one: "0x820fef5837650fa3b8e45045b88059d8deaf0810350ec511c47ef768a28c2c9b"; + 256, 4 => + minus_one: "0x1cee60e39b32a4541f1091f87056012cddcb97999dbd2368e30c2354007ec737", + min: "0x39a3aec1ef2b24058158e9609c2ad332ee9bda7e4ea237ba1c4f9e8777f1da6d", + one: "0x156774b33c8bc7cb83eda4cbc43b36c7c9490ff8913c488ccd5132cfc71344ea"; + } + } + + #[test] + fn unsigned_keys_are_zero_extended_like_solidity() { + check_unsigned! { + 8, 1 => + one: "0xcc69885fda6bcc1a4ace058b4a62bf5e179ea78fd58a1ccd71c22cc9b688792f", + max: "0x24a9e90595537a4321bf3a8fd43f02c179fe79a94dde54a8c1a057e2967a4d0b"; + 16, 1 => + one: "0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0", + max: "0x695395ec6a2c9d3a74f1c3d78bda956395489a2ec3ce495c3087446ede7bcc9d"; + 24, 1 => + one: "0xa15bc60c955c405d20d9149c709e2460f1c2d9a497496a7f46004d1772c3054c", + max: "0x718d5407b17d62afeebaa1cf05ee0d79f9c7fd9d1af5f32d660e507ee3646281"; + 32, 1 => + one: "0xabd6e7cb50984ff9c2f3e18a2660c3353dadf4e3291deeb275dae2cd1e44fe05", + max: "0x2484d89f5223ef64143beaf0a743715076229ba3cba1b81333f195bbd499c493"; + 40, 1 => + one: "0x1471eb6eb2c5e789fc3de43f8ce62938c7d1836ec861730447e2ada8fd81017b", + max: "0x860d2b901dd1865e65ce65feadabdd4bf4261744a4f7cca964c9134b799b5f3f"; + 48, 1 => + one: "0x3e5fec24aa4dc4e5aee2e025e51e1392c72a2500577559fae9665c6d52bd6a31", + max: "0xb3304aff9a7e727966dec372983e06a978d7b53336011327d28f721b162ecefd"; + 56, 1 => + one: "0xb39221ace053465ec3453ce2b36430bd138b997ecea25c1043da0c366812b828", + max: "0x09658940dc07950a96aebbddd25447b8b5f800c210571b206f52775408ce6e50"; + 64, 1 => + one: "0xad67d757c34507f157cacfa2e3153e9f260a2244f30428821be7be64587ac55f", + max: "0x0b7244c513877c415f365e3266d1f5b7b85386b2438257e95327690b197b4523"; + 72, 2 => + one: "0x92e85d02570a8092d09a6e3a57665bc3815a2699a4074001bf1ccabf660f5a36", + max: "0xb736b02a15d164f558824358adc5b6574a53d00def4ef517f5feb50e14e447e7"; + 80, 2 => + one: "0xbbc70db1b6c7afd11e79c0fb0051300458f1a3acb8ee9789d9b6b26c61ad9bc7", + max: "0xf0cfa32fa27376ddfd98a9d7e448b70e3ca96b510e44a94af08d96e6cb4355e8"; + 88, 2 => + one: "0x72c6bfb7988af3a1efa6568f02a999bc52252641c659d85961ca3d372b57d5cf", + max: "0x1c9ad027e949c8558ca0b9cfbe9321a43a6dc3c8025ec79bb8274e18f36f0d01"; + 96, 2 => + one: "0xd421a5181c571bba3f01190c922c3b2a896fc1d84e86c9f17ac10e67ebef8b5c", + max: "0x8118b5322632f7d46ecb6b2da464f1fbf0caf4ab099821f1f1ab3d81f3fbabbd"; + 104, 2 => + one: "0xfd54ff1ed53f34a900b24c5ba64f85761163b5d82d98a47b9bd80e45466993c5", + max: "0xdff27520ef1cea0797109ce6edf36570a0db5647650f533191e196a859ca6785"; + 112, 2 => + one: "0xa7c5ba7114a813b50159add3a36832908dc83db71d0b9a24c2ad0f83be958207", + max: "0x3569203d6d22d565dad3f082b918a10628b2eb8a3f630d286645e9adb55b4667"; + 120, 2 => + one: "0x169f97de0d9a84d840042b17d3c6b9638b3d6fd9024c9eb0c7a306a17b49f88f", + max: "0x2a5c4fc39620558873ff0ca91ef8042e0874e9f22afbbacebe38117c09d5065c"; + 128, 2 => + one: "0x8c6065603763fec3f5742441d3833f3f43b982453612d76adb39a885e3006b5f", + max: "0x36da9b7b57d3afaa3e6cc456e64eaa514b2ae0d30afc92ea1ec06b30e88959ab"; + 136, 3 => + one: "0x17bc176d2408558f6e4111feebc3cab4e16b63e967be91cde721f4c8a488b552", + max: "0x57936fb2401e866936d5b5695657b1670cf656fca1bee51e67e4491404526062"; + 144, 3 => + one: "0x71a67924699a20698523213e55fe499d539379d7769cd5567e2c45d583f815a3", + max: "0x9ef5f3e12c03008c67378b967164e0161f5f1d6ae34f6df01b9378e2b80beadc"; + 152, 3 => + one: "0x4155c2f711f2cdd34f8262ab8fb9b7020a700fe7b6948222152f7670d1fdf34d", + max: "0xe5f97db8198f87a655acf93d438efde235df6dd6ebb20907d6dc820d5f796456"; + 160, 3 => + one: "0xb6c61a840592cc84133e4b25bd509abf4659307c57b160799b38490a5aa48f2c", + max: "0xeef591571549cf9b667da255ab4bb2a90dfdcb77845d7bc32c3bf4528eee03db"; + 168, 3 => + one: "0x27739e4bb5e6f8b5e4b57a047dca8767cc9b982a011081e086cbb0dfa9de818d", + max: "0x686ab6ea74c6cdd8d7a5a648013643a6089d7e22d717fcb2ac5dbd8f399efa46"; + 176, 3 => + one: "0x4c4dc693d7db52f85fe052106f4b4b920e78e8ef37dee82878a60ab8585faf49", + max: "0x8c47b3ab20072ae7cdac9c75ff8dc45df62976a10a42e4e64a81c8ffa7b28da8"; + 184, 3 => + one: "0xf36d6bc9642eb6fb6ee9998b09ce990566df752ab06e11f8de7ab633bbd57b8f", + max: "0xab8392575d39e8d03687a5ef4e7737e5c8d9ec34707ef18d29959fd21970ac42"; + 192, 3 => + one: "0xf3794665d3af9b6fb6f858b70185898134f96768ef31c325d52e04f0ac195a4d", + max: "0x027dd54657a2f84ad9e3a6e71d350c80b0d312217186b9864e9888fc992b7768"; + 200, 4 => + one: "0xfc941c3961fb6541da34150022cddf959da0fb2353866a6bfbd249c2da092914", + max: "0x9c54dd50d8ee03455137bfa590f54346660cc892134e199e5ec1fec01a75ca76"; + 208, 4 => + one: "0xf88cd8d612926ebb404e40725c01084b6e9b3ce0344cde068570342cbd448c61", + max: "0x44e8583e57d9bd6020882773d64cd6cad8c3a1b1a6041765b2b65abf45523c70"; + 216, 4 => + one: "0x9fafca4c9c0d5c2cbf85f49fd8ab8212430ce78c2a0cb75b51e0f9c4f9ace003", + max: "0x2151d824bbd7f3507ba0b5bb2bb48e15583b93b62a0eac1ed6b8f03bf2a7e709"; + 224, 4 => + one: "0x6de76108811faf2f94afbe5ac6c98e8393206cd093932de1fbfd61bbeec43a02", + max: "0x01ed95526d43b72addeec4f9ab6c4aeb19ceee53677af91de7bb975394286e51"; + 232, 4 => + one: "0x9de6abd965d55c3bb0cdbf6fa175050624c6ff8fe86f682dc08f2a450ede2278", + max: "0xb203bc3d27897c0bf469cc2ad67d0630c7c227efd02cf9ae38c4d4f68c8d463f"; + 240, 4 => + one: "0x873299c6a6c39b8b92f01922bb622df4a3236ea2876aac2da76f6c092cf7e98f", + max: "0x6910e3949173da8fdaf64e896cb500f1a4f7ff86db75644ab9817f15a26075a1"; + 248, 4 => + one: "0x820fef5837650fa3b8e45045b88059d8deaf0810350ec511c47ef768a28c2c9b", + max: "0x4932347b43efb2bde30d2fe9b51cdf92645c8ca102ca267425da755c76269ffc"; + 256, 4 => + one: "0x156774b33c8bc7cb83eda4cbc43b36c7c9490ff8913c488ccd5132cfc71344ea", + max: "0x1cee60e39b32a4541f1091f87056012cddcb97999dbd2368e30c2354007ec737"; + } + } + + #[test] + fn fixed_bytes_keys_are_left_aligned_like_solidity() { + check_fixed_bytes! { + 1 => "0x5fe77fe1715fa199167260892560bdfea7e3beca6538b14e3cebe1c4589fdc43"; + 2 => "0x40a782713f2a3a2841a641caf541d0994d36ff6ea1a60eaa1288ac0d11b4f63f"; + 3 => "0x16eedc1fe3f8b776707c3ddb3c97201d29815a2f8012a46e44acd8af119a503e"; + 4 => "0xe4489ab818d06fb7da5f73f86bb1c6d2abd37a194e7cb70804ef28265fa7a9ab"; + 5 => "0xa9f0230be17a475d0a20d504ec12af13f6cb1bc73ae783c0ba6cc8c7c11a1e1f"; + 6 => "0xc543d1c87654074c97398ed772fce557c63ca577aad067ab67876aebb88831fd"; + 7 => "0xc4d86d1b22ec90707cb87c37b247b1efb4b4e61c931be0a2a1d2d42ab734ad3e"; + 8 => "0x8b055d49df0ec9075f57a156d7cae08d3f36d1196cbfa691fa281f8e1812a06e"; + 9 => "0x74420e9e5e968bbb355f2022e6302b1485324fb1b8c15543f0fc55507bedaffd"; + 10 => "0x98e7c97affe4f9f51cb0dd3934578d0675c1431f4bba47f97d27d0d0877f2c04"; + 11 => "0xc2b0e1b47476a33d86a6294b4e4720e9b3cdc679d2876552085bd385d08504da"; + 12 => "0x2e85ed8801d95db48ad451d263fcd2147108e4bcd6f37863f9595558f65387d8"; + 13 => "0x492ea390ccef3421809b276cbd0df7e89c68c408d72c0c200b3f30340a8117de"; + 14 => "0x5f214951563d58225ed61330c65558c491692c06c15980fbd53b39495463c58d"; + 15 => "0x8193b817808b613a1308631ba6b39cb0cd15c952596400689e29fdf284b4305c"; + 16 => "0x8cf51f27d73d21467722f0bed7d16afc4f4db350efece39fd9c6581fe608f196"; + 17 => "0x81e63e95cf4c7d6680478c0dca20a3a44216a6b3beac9aaa9acf3310785705cd"; + 18 => "0x451c1b1ec7bf3897524ebba61e030d18b18e0d6f70020bab78e6020eb453d98e"; + 19 => "0x227d3eded325e4295c9f615c8d2248808b0102562394e2d6020eafaec450cb94"; + 20 => "0xfae520aea0558aedacb48cb853e9c6886ae2da5a3ce147d2f37404522bf248be"; + 21 => "0xafa44a56a258e51747727e5c6e5bcd9e632dd0fb5e084cb64fb403a221025113"; + 22 => "0xd6d1a238bd4a71d6e29b466cf7468fee2da0b1fc1cae2a6909f7838e28b03d83"; + 23 => "0x1cf7eb14d69eb2ee590afc26df355f3fb87db1e9af6fb7f740298dbec72df7c2"; + 24 => "0xfddb01400ce1b4119ddbac337182c61e09adb32ee040af815d10e2236196baf2"; + 25 => "0x86df4719e1b22d3006da6b235286866f06bcdc1d711fd548a0cb59fa5798d980"; + 26 => "0x1836c75efc1d4a3f0f54bf154f71bcb723aeb357363e0374ab428e993cd5d5b1"; + 27 => "0xa44908b0cabec0ef9bcd2362c8b1b9c5615a1f15955174a3901d43c3158687a8"; + 28 => "0x451a5043e968379117975425198af6071afa481f49967c7dff959205047d7a9a"; + 29 => "0xf7206f9a32691b10d4b5b82bf54c8d57762142c7e2a32af920699a43ed172031"; + 30 => "0xf97fe617bd6d2a78d364ce68f6b5a864878246c84dd87fddf1bc78fc50baceb7"; + 31 => "0x616cfe9ccea956a0709082df9b3ad6595ce832c5f3819b69907cc9c7ee611da4"; + 32 => "0x6b14529b1bcf7e64a89c20b33b87a9d86cd17195eaf5bb8aa98a83ef8634a06d"; + } + } + + /// Rust's own integer types have their own impls, so they get their own + /// check against the matching Solidity width. + #[test] + fn rust_integer_keys_match_solidity() { + assert_eq!( + (-1i8).compute_slot(U256::from(1)), + expect("0xc39d774f18115b85b81494d65e588b565d73abc969333d1da7b0a0eb0729accd") + ); + assert_eq!( + i8::MIN.compute_slot(U256::from(1)), + expect("0xa6448894d065a3e7161c6293c2db5e883893c02ad215165c869901858b9036a9") + ); + assert_eq!( + 1i8.compute_slot(U256::from(1)), + expect("0xcc69885fda6bcc1a4ace058b4a62bf5e179ea78fd58a1ccd71c22cc9b688792f") + ); + assert_eq!( + (-1i16).compute_slot(U256::from(2)), + expect("0x38b5b2ceac7637132d27514ffcf440b705287635075af7b8bd5adcaa6a4cc5bb") + ); + assert_eq!( + i16::MIN.compute_slot(U256::from(2)), + expect("0xcf2fb756914b19221bf2fef06148e4b4b9392b9c9a1f0bc7d93709ef28342ba5") + ); + assert_eq!( + (-1i32).compute_slot(U256::from(4)), + expect("0xd8c80a9840ed58f33f2186a8fbc29ecd8c3610d196f1da047301bd51988eb95c") + ); + assert_eq!( + i32::MIN.compute_slot(U256::from(4)), + expect("0x6e55b48d4744edeccafa886884bc9a4dbd00a777c2dcd5791e8b167088622824") + ); + assert_eq!( + (-1i64).compute_slot(U256::from(8)), + expect("0x50015d2c5500ee864adc0ae35838917a2d9a98eb2ab97342b4d689d9a074dbfa") + ); + assert_eq!( + i64::MIN.compute_slot(U256::from(8)), + expect("0xb6f2a08fd84fd72638abdb192e8c8e97b6d535d5052c4a0421762dcd1a7379cb") + ); + assert_eq!( + (-1i128).compute_slot(U256::from(16)), + expect("0x67c618532631f5e38a3b9c5e06a3e5553e5fac44409c6f6f7364f2525b56773a") + ); + assert_eq!( + i128::MIN.compute_slot(U256::from(16)), + expect("0x5a065330214f8c8ac10a99d46ab28c8765aec8bb0237b9ab0d1086283ca05320") + ); + + assert_eq!( + 1u8.compute_slot(U256::from(1)), + expect("0xcc69885fda6bcc1a4ace058b4a62bf5e179ea78fd58a1ccd71c22cc9b688792f") + ); + assert_eq!( + u8::MAX.compute_slot(U256::from(1)), + expect("0x24a9e90595537a4321bf3a8fd43f02c179fe79a94dde54a8c1a057e2967a4d0b") + ); + assert_eq!( + 1u16.compute_slot(U256::from(2)), + expect("0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0") + ); + assert_eq!( + u16::MAX.compute_slot(U256::from(2)), + expect("0x695395ec6a2c9d3a74f1c3d78bda956395489a2ec3ce495c3087446ede7bcc9d") + ); + assert_eq!( + 1u32.compute_slot(U256::from(4)), + expect("0xabd6e7cb50984ff9c2f3e18a2660c3353dadf4e3291deeb275dae2cd1e44fe05") + ); + assert_eq!( + u32::MAX.compute_slot(U256::from(4)), + expect("0x2484d89f5223ef64143beaf0a743715076229ba3cba1b81333f195bbd499c493") + ); + assert_eq!( + 1u64.compute_slot(U256::from(8)), + expect("0xad67d757c34507f157cacfa2e3153e9f260a2244f30428821be7be64587ac55f") + ); + assert_eq!( + u64::MAX.compute_slot(U256::from(8)), + expect("0x0b7244c513877c415f365e3266d1f5b7b85386b2438257e95327690b197b4523") + ); + assert_eq!( + 1u128.compute_slot(U256::from(16)), + expect("0x8c6065603763fec3f5742441d3833f3f43b982453612d76adb39a885e3006b5f") + ); + assert_eq!( + u128::MAX.compute_slot(U256::from(16)), + expect("0x36da9b7b57d3afaa3e6cc456e64eaa514b2ae0d30afc92ea1ec06b30e88959ab") + ); + } + + #[test] + fn address_bool_and_dynamic_keys_match_solidity() { + assert_eq!( + Address::from(ADDR_A).compute_slot(U256::from(1)), + expect("0x3c57502180841def3a766b77feb940197842ed9efd28b2f50e0f70458b82580a"), + "address key", + ); + assert_eq!( + true.compute_slot(U256::from(2)), + expect("0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0"), + "bool key true", + ); + assert_eq!( + false.compute_slot(U256::from(2)), + expect("0xac33ff75c19e70fe83507db0d683fd3465c996598dc972688b7ace676c89077b"), + "bool key false", + ); + assert_eq!( + "alice".compute_slot(U256::from(5)), + expect("0xfc294032e6b5f0d6e44152b2f364949f25109ae791ffea493e0e54b8b816e667"), + "string key", + ); + let bytes_key: &[u8] = &[0xde, 0xad, 0xbe, 0xef]; + assert_eq!( + bytes_key.compute_slot(U256::from(6)), + expect("0x815d41eb90fe7596bc0972a013d6cf8a713e439fd2fd2d44db1a23abf96a0321"), + "bytes key", + ); + } + + #[test] + fn nested_map_slots_match_solidity() { + // mapping(address => mapping(address => uint256)) at slot 3 + let nested = StorageMap::>>::new( + U256::from(3), + ); + assert_eq!( + nested + .entry(Address::from(ADDR_A)) + .entry(Address::from(ADDR_B)) + .slot(), + expect("0xb3632e2932f8172291298022e8bb1f6e4c21b49aef132143d2bb7a105cd0eedc"), + ); + + // mapping(int24 => mapping(bytes4 => uint256)) at slot 4: both levels + // pad the key differently from the packed storage layout. + let mixed = StorageMap::< + Signed<24, 1>, + StorageMap, StoragePrimitive>, + >::new(U256::from(4)); + assert_eq!( + mixed + .entry(Signed::<24, 1>::MINUS_ONE) + .entry(FixedBytes::<4>::from([0xde, 0xad, 0xbe, 0xef])) + .slot(), + expect("0x24e38c94117735d9899d5475e6d23fc3728dea0a5a9171209193b99a6074fb15"), + ); + } + + /// `address` and `bytes20` carry the same 20 bytes but land on different + /// slots in Solidity: one is right-aligned, the other left-aligned. + #[test] + fn address_and_bytes20_keys_do_not_collide() { + let base = U256::from(1); + let as_address = Address::from(ADDR_A).compute_slot(base); + let as_bytes20 = FixedBytes::<20>::from(ADDR_A).compute_slot(base); + + assert_ne!(as_address, as_bytes20); + assert_eq!( + as_address, + expect("0x3c57502180841def3a766b77feb940197842ed9efd28b2f50e0f70458b82580a") + ); + } + } + #[test] fn test_map_zero_key() { let mut sdk = MockStorage::new(); diff --git a/crates/sdk/src/storage/primitive.rs b/crates/sdk/src/storage/primitive.rs index 80fc4a02f..4a7c45a01 100644 --- a/crates/sdk/src/storage/primitive.rs +++ b/crates/sdk/src/storage/primitive.rs @@ -1,5 +1,5 @@ use crate::{ - storage::{PackableCodec, StorageDescriptor, StorageLayout, StorageOps}, + storage::{MapKeyCodec, PackableCodec, StorageDescriptor, StorageLayout, StorageOps}, Address, FixedBytes, Signed, StorageAPI, Uint, U256, }; use core::marker::PhantomData; @@ -120,6 +120,59 @@ macro_rules! impl_int_codec { impl_int_codec!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); +// --- MapKeyCodec implementations --- +// +// Solidity pads a mapping key the way its type is laid out in memory, which is not +// the packed storage layout: `intN` is sign-extended and `bytesN` is left-aligned. + +/// `h(k)` for types Solidity right-aligns in a word: `uintN`, `address`, `bool`. +fn zero_extended_key(value: &T) -> [u8; 32] { + let mut word = [0u8; 32]; + value.encode_into(&mut word[32 - T::ENCODED_SIZE..]); + word +} + +/// `h(k)` for `intN`: the two's complement encoding widened to 32 bytes. +fn sign_extended_key(value: &T, is_negative: bool) -> [u8; 32] { + let mut word = if is_negative { [0xffu8; 32] } else { [0u8; 32] }; + value.encode_into(&mut word[32 - T::ENCODED_SIZE..]); + word +} + +/// `h(k)` for `bytesN`: left-aligned, zero-padded on the right. +fn right_padded_key(value: &T) -> [u8; 32] { + let mut word = [0u8; 32]; + value.encode_into(&mut word[..T::ENCODED_SIZE]); + word +} + +macro_rules! impl_zero_extended_key { + ($($ty:ty),* $(,)?) => { + $( + impl MapKeyCodec for $ty { + fn encode_key_word(&self) -> [u8; 32] { + zero_extended_key(self) + } + } + )* + }; +} + +macro_rules! impl_sign_extended_key { + ($($ty:ty),* $(,)?) => { + $( + impl MapKeyCodec for $ty { + fn encode_key_word(&self) -> [u8; 32] { + sign_extended_key(self, self.is_negative()) + } + } + )* + }; +} + +impl_zero_extended_key!(bool, Address, u8, u16, u32, u64, u128); +impl_sign_extended_key!(i8, i16, i32, i64, i128); + // Macro for Uint types (standard Solidity sizes) macro_rules! impl_uint_codec { ($($bits:literal => $limbs:literal),*) => { @@ -138,6 +191,12 @@ macro_rules! impl_uint_codec { Self::from_be_bytes::<{ $bits / 8 }>(bytes.try_into().unwrap()) } } + + impl MapKeyCodec for Uint<$bits, $limbs> { + fn encode_key_word(&self) -> [u8; 32] { + zero_extended_key(self) + } + } )* }; } @@ -160,6 +219,12 @@ macro_rules! impl_signed_codec { Self::from_be_bytes::<{ $bits / 8 }>(bytes.try_into().unwrap()) } } + + impl MapKeyCodec for Signed<$bits, $limbs> { + fn encode_key_word(&self) -> [u8; 32] { + sign_extended_key(self, self.is_negative()) + } + } )* }; } @@ -181,6 +246,12 @@ macro_rules! impl_fixed_bytes_codec { FixedBytes::from_slice(bytes) } } + + impl MapKeyCodec for FixedBytes<$n> { + fn encode_key_word(&self) -> [u8; 32] { + right_padded_key(self) + } + } )* }; } diff --git a/crates/sdk/src/storage/vec.rs b/crates/sdk/src/storage/vec.rs index 3856c24ed..0555fbffd 100644 --- a/crates/sdk/src/storage/vec.rs +++ b/crates/sdk/src/storage/vec.rs @@ -94,17 +94,38 @@ where (elements_base + U256::from(slot_index), offset) } else { - // Non-packable elements - (elements_base + U256::from(index * T::SLOTS as u64), 0) + // Non-packable elements. The multiplication is done in U256 so that a large index + // cannot wrap a u64 and alias an earlier element (contracts build with + // `overflow-checks = false`). + (elements_base + U256::from(index) * U256::from(T::SLOTS), 0) } } - /// Access element at index (no bounds check). + /// Access element at index without checking it against the current length. + /// + /// Distinct indices always map to distinct storage locations, but indices past the + /// length address slots the vector does not own yet. Prefer [`Self::get`]. pub fn at(&self, index: u64) -> T::Accessor { let (slot, offset) = self.element_location(index); T::access(T::Descriptor::new(slot, offset)) } + /// Access element at index, returning `None` when it is out of bounds. + pub fn get(&self, sdk: &S, index: u64) -> Option { + self.get_checked(sdk, index).unwrap() + } + + pub fn get_checked( + &self, + sdk: &S, + index: u64, + ) -> Result, ExitCode> { + if index >= self.len_checked(sdk)? { + return Ok(None); + } + Ok(Some(self.at(index))) + } + /// Grow vector by one and return accessor to new element. pub fn grow(&self, sdk: &mut S) -> T::Accessor { self.grow_checked(sdk).unwrap() @@ -201,10 +222,54 @@ where mod tests { use super::*; use crate::storage::{ + array::StorageArray, mock::MockStorage, - primitive::{StorageU256, StorageU64}, + primitive::{StorageU256, StorageU64, StorageU8}, }; + /// Indices spanning both the packing boundaries and the u64 values that used to wrap. + const PROBE_INDICES: [u64; 14] = [ + 0, + 1, + 2, + 3, + 4, + 7, + 31, + 32, + 33, + 1 << 32, + (1 << 63) - 1, + 1 << 63, + u64::MAX - 1, + u64::MAX, + ]; + + /// Total order over element addresses: slots grow with the index, and elements packed + /// inside one slot are laid out right to left (so a lower offset means a later element). + fn address_key(vec: &StorageVec, index: u64) -> (U256, u8) + where + T::Descriptor: StorageDescriptor, + { + let (slot, offset) = vec.element_location(index); + (slot.wrapping_sub(vec.elements_base_slot()), 32 - offset) + } + + fn assert_addresses_monotonic(vec: &StorageVec) + where + T::Descriptor: StorageDescriptor, + { + for pair in PROBE_INDICES.windows(2) { + let (lower, higher) = (pair[0], pair[1]); + assert!( + address_key(vec, lower) < address_key(vec, higher), + "index {higher} does not address a later location than {lower} (SLOTS={}, BYTES={})", + T::SLOTS, + T::BYTES, + ); + } + } + #[test] fn test_vec_primitive_api() { // Critical: test specialized push/pop for primitives @@ -285,4 +350,48 @@ mod tests { assert_eq!(removed.at(0).get(&sdk), U256::from(30)); // Can still read assert_eq!(vec.len(&sdk), 1); // But length updated } + + #[test] + fn test_vec_large_index_does_not_alias_earlier_element() { + // Elements of this vector reserve 2 slots each, so `index * SLOTS` used to wrap a + // u64 back to 0 at index 2^63 and alias element 0. + let vec = StorageVec::>::new(U256::from(400)); + let elements_base = vec.elements_base_slot(); + + assert_eq!(vec.element_location(0), (elements_base, 0)); + + let (slot, offset) = vec.element_location(1 << 63); + assert_eq!(offset, 0); + assert_eq!( + slot.wrapping_sub(elements_base), + U256::from(1u64 << 63) * U256::from(2) + ); + } + + #[test] + fn test_vec_addresses_are_monotonic_for_all_widths() { + // Packed elements, one element per slot, and multi-slot elements. + assert_addresses_monotonic(&StorageVec::::new(U256::from(401))); + assert_addresses_monotonic(&StorageVec::::new(U256::from(402))); + assert_addresses_monotonic(&StorageVec::::new(U256::from(403))); + assert_addresses_monotonic(&StorageVec::>::new( + U256::from(404), + )); + assert_addresses_monotonic(&StorageVec::>::new( + U256::from(405), + )); + } + + #[test] + fn test_vec_get_rejects_out_of_bounds() { + let mut sdk = MockStorage::new(); + let vec = StorageVec::::new(U256::from(500)); + + assert!(vec.get(&sdk, 0).is_none()); + + vec.push(&mut sdk, U256::from(111)); + assert_eq!(vec.get(&sdk, 0).unwrap().get(&sdk), U256::from(111)); + assert!(vec.get(&sdk, 1).is_none()); + assert!(vec.get(&sdk, u64::MAX).is_none()); + } } diff --git a/crates/sdk/src/types/context.rs b/crates/sdk/src/types/context.rs index ac1670616..2082c3589 100644 --- a/crates/sdk/src/types/context.rs +++ b/crates/sdk/src/types/context.rs @@ -49,6 +49,13 @@ impl SharedContextInput { } } +/// Block-level context handed to delegated runtimes. +/// +/// Deliberately carries no active hardfork / `SpecId`. Delegated runtimes are versioned by +/// contract upgrade rather than by fork activation, so the EVM runtime pins its own spec instead +/// of following the chain spec — see the `fluentbase_evm::evm` module docs. Adding a fork field +/// here to gate delegated opcodes would reintroduce the hardfork coupling that forkless runtime +/// upgrades exist to avoid. #[derive(Default, Clone, Debug, PartialEq)] pub struct BlockContextV1 { pub chain_id: u64, @@ -71,6 +78,13 @@ pub struct ContractContextV1 { pub gas_limit: u64, } +/// Transaction-level context handed to delegated runtimes. +/// +/// Deliberately carries no EIP-4844 blob fields (versioned hashes, max fee per blob gas). Fluent +/// does not support blob transactions: blocks carry no `excess_blob_gas` / `blob_gas_used` and the +/// blob schedule is empty (`crates/genesis/build.rs`), so a type-3 transaction can never be +/// included and there is nothing to plumb through. The delegated EVM's `BLOBHASH` / `BLOBBASEFEE` +/// consequently return zero by design — see `fluentbase_evm::host`. #[derive(Default, Clone, Debug, PartialEq)] pub struct TxContextV1 { pub gas_limit: u64, @@ -78,8 +92,6 @@ pub struct TxContextV1 { pub gas_price: U256, pub gas_priority_fee: Option, pub origin: Address, - // pub blob_hashes: Vec, - // pub max_fee_per_blob_gas: Option, pub value: U256, } diff --git a/crates/sdk/src/types/storage.rs b/crates/sdk/src/types/storage.rs index e4fb25f20..629848e3b 100644 --- a/crates/sdk/src/types/storage.rs +++ b/crates/sdk/src/types/storage.rs @@ -19,22 +19,23 @@ impl StorageUtils for T { if let Some(end) = value.iter().position(|c| *c == 0u8) { value = &value[..end]; } - let result = str::from_utf8(value).unwrap().to_string(); + // Stored words are not guaranteed to be well-formed: they can come from genesis, + // a legacy layout, or a raw storage write. Report malformed bytes instead of + // panicking, which would permanently brick every reader of this slot. + let result = str::from_utf8(value) + .map_err(|_| ExitCode::MalformedBuiltinParams)? + .to_string(); Ok(result) } fn write_storage_short_string(&mut self, slot: U256, value: &str) -> Result<(), ExitCode> { - debug_assert!( - value.len() <= U256::BYTES, - "system: short string can't exceed 32 bytes" - ); - let mut bytes32 = [0u8; U256::BYTES]; - let bytes = value.as_bytes(); - if bytes.len() > U256::BYTES { - bytes32.copy_from_slice(&bytes[..U256::BYTES]); - } else { - bytes32[..bytes.len()].copy_from_slice(bytes); + // Reject before mutating storage. Truncating to 32 bytes can split a UTF-8 code + // point and persist bytes that no reader can decode. + if value.len() > U256::BYTES { + return Err(ExitCode::MalformedBuiltinParams); } + let mut bytes32 = [0u8; U256::BYTES]; + bytes32[..value.len()].copy_from_slice(value.as_bytes()); let value = U256::from_be_bytes(bytes32); self.write_storage(slot, value).ok() } @@ -57,6 +58,7 @@ pub fn storage_mapping_slot() {} #[cfg(test)] mod tests { use crate::{types::storage::StorageUtils, StorageAPI, U256}; + use alloc::format; use fluentbase_types::{ExitCode, SyscallResult}; use hashbrown::HashMap; @@ -70,7 +72,7 @@ mod tests { } fn storage(&self, slot: &U256) -> SyscallResult { - let result = self.0.get(slot).cloned().unwrap(); + let result = self.0.get(slot).cloned().unwrap_or_default(); SyscallResult::new(result, 0, 0, ExitCode::Ok) } } @@ -84,4 +86,53 @@ mod tests { let value = storage.storage_short_string(&U256::ZERO).unwrap(); assert_eq!(value, "Hello, World!"); } + + #[test] + fn test_short_string_accepts_exactly_32_bytes() { + let mut storage = TestingStorage::default(); + let ascii = "a".repeat(U256::BYTES); + storage + .write_storage_short_string(U256::ZERO, &ascii) + .unwrap(); + assert_eq!(storage.storage_short_string(&U256::ZERO).unwrap(), ascii); + + // 32 bytes of multibyte text is also a valid boundary (8 * 4-byte code points). + let multibyte = "😀".repeat(8); + assert_eq!(multibyte.len(), U256::BYTES); + storage + .write_storage_short_string(U256::ONE, &multibyte) + .unwrap(); + assert_eq!(storage.storage_short_string(&U256::ONE).unwrap(), multibyte); + } + + #[test] + fn test_short_string_rejects_overlong_without_mutating() { + // 33 ASCII bytes, and 33 bytes whose 32-byte prefix would split a code point. + for overlong in ["a".repeat(U256::BYTES + 1), format!("{}é", "a".repeat(31))] { + assert_eq!(overlong.len(), U256::BYTES + 1); + let mut storage = TestingStorage::default(); + assert_eq!( + storage.write_storage_short_string(U256::ZERO, &overlong), + Err(ExitCode::MalformedBuiltinParams) + ); + assert!( + storage.0.is_empty(), + "rejected write must not touch storage" + ); + } + } + + #[test] + fn test_short_string_read_of_malformed_bytes_errors() { + let mut storage = TestingStorage::default(); + // A lone continuation byte: never valid UTF-8, and reachable via genesis or a + // legacy raw-bytes layout. + let mut word = [0u8; U256::BYTES]; + word[0] = 0x80; + let _ = storage.write_storage(U256::ZERO, U256::from_be_bytes(word)); + assert_eq!( + storage.storage_short_string(&U256::ZERO), + Err(ExitCode::MalformedBuiltinParams) + ); + } } diff --git a/crates/sdk/src/universal_token.rs b/crates/sdk/src/universal_token.rs index 12966a409..cac34f527 100644 --- a/crates/sdk/src/universal_token.rs +++ b/crates/sdk/src/universal_token.rs @@ -20,7 +20,7 @@ extern crate alloc; -use crate::{Address, Bytes, U256}; +use crate::{Address, Bytes, B256, U256}; use alloc::string::String; /// Re-export the precompile address for convenience pub use fluentbase_types::PRECOMPILE_UNIVERSAL_TOKEN_RUNTIME; @@ -179,9 +179,19 @@ impl TokenConfigBuilder { /// Build the `TokenConfig` or return an error pub fn try_build(self) -> Result { + let name = self.name.ok_or(TokenConfigError::MissingName)?; + let symbol = self.symbol.ok_or(TokenConfigError::MissingSymbol)?; + // Metadata is stored as a single 32-byte word. Reject overlong values here rather + // than truncating them into a token that deploys under a different name. + if name.len() > B256::len_bytes() { + return Err(TokenConfigError::NameTooLong); + } + if symbol.len() > B256::len_bytes() { + return Err(TokenConfigError::SymbolTooLong); + } Ok(TokenConfig { - name: self.name.ok_or(TokenConfigError::MissingName)?, - symbol: self.symbol.ok_or(TokenConfigError::MissingSymbol)?, + name, + symbol, decimals: self.decimals.unwrap_or(18), initial_supply: self.initial_supply.unwrap_or(U256::ZERO), minter: self.minter, @@ -198,6 +208,10 @@ pub enum TokenConfigError { MissingName, /// Token symbol was not provided MissingSymbol, + /// Token name exceeds the 32-byte short-string limit + NameTooLong, + /// Token symbol exceeds the 32-byte short-string limit + SymbolTooLong, } impl core::fmt::Display for TokenConfigError { @@ -205,6 +219,8 @@ impl core::fmt::Display for TokenConfigError { match self { TokenConfigError::MissingName => write!(f, "token name is required"), TokenConfigError::MissingSymbol => write!(f, "token symbol is required"), + TokenConfigError::NameTooLong => write!(f, "token name must not exceed 32 bytes"), + TokenConfigError::SymbolTooLong => write!(f, "token symbol must not exceed 32 bytes"), } } } diff --git a/crates/sdk/src/universal_token/storage.rs b/crates/sdk/src/universal_token/storage.rs index efedce069..a5b3b517c 100644 --- a/crates/sdk/src/universal_token/storage.rs +++ b/crates/sdk/src/universal_token/storage.rs @@ -37,14 +37,40 @@ impl From<&str> for TokenNameOrSymbol { } impl TokenNameOrSymbol { + /// Wraps a raw 32-byte metadata word as it appears in calldata or storage. + /// + /// The bytes are not validated; use [`Self::as_str`] to decode them. + pub const fn from_word(bytes: B256) -> Self { + Self { bytes } + } + + /// Encodes `value` as a 32-byte short string, or returns `None` if it does not fit. + /// + /// Prefer this over [`Self::from_str`]: it reports overlong metadata instead of + /// silently shortening it to a different token name. + pub fn try_from_str(value: &str) -> Option { + if value.len() > B256::len_bytes() { + return None; + } + let mut bytes = B256::ZERO; + bytes[..value.len()].copy_from_slice(value.as_bytes()); + Some(Self { bytes }) + } + + /// Encodes `value` as a 32-byte short string, truncating anything longer. + /// + /// Truncation is lossy and silent — it behaves the same in debug and release, so it + /// cannot be relied on to surface overlong metadata. Use [`Self::try_from_str`] when + /// the caller needs to know the name did not fit. #[allow(clippy::should_implement_trait)] pub fn from_str(value: &str) -> Self { - debug_assert!(value.len() <= B256::len_bytes()); + // Truncate on a char boundary. Slicing at a fixed 32 bytes can land inside a + // multi-byte code point, and the resulting word decodes nowhere. + let mut len = core::cmp::min(B256::len_bytes(), value.len()); + while len > 0 && !value.is_char_boundary(len) { + len -= 1; + } let mut bytes = B256::ZERO; - let len = core::cmp::min(B256::len_bytes(), value.len()); - // Slice the source too: when value is longer than 32 bytes, `len` is - // clamped to 32 but `value.as_bytes()` is not, so copy_from_slice would - // panic on the length mismatch. Truncate to the first `len` bytes. bytes[..len].copy_from_slice(&value.as_bytes()[..len]); Self { bytes } } @@ -70,8 +96,13 @@ pub struct LegacyInitialSettings { } impl LegacyInitialSettings { + /// Decodes a legacy payload, requiring the input to be exactly one payload long. + /// + /// The decoder itself only checks that the buffer is *at least* large enough, so an exact + /// length check here is what stops a caller from accepting — and later persisting — bytes + /// that carry no token semantics. pub fn decode_with_prefix(buf: &[u8]) -> Option { - if buf.len() < 4 { + if buf.len() != INITIAL_SETTINGS_LEGACY_SIZE { return None; } let (sig, buf) = buf.split_at(4); @@ -87,6 +118,16 @@ impl LegacyInitialSettings { pub const INITIAL_SETTINGS_V1_SIZE: usize = 4 + 6 * 32; pub const INITIAL_SETTINGS_V2_SIZE: usize = 4 + 7 * 32; +/// Legacy payload size including magic prefix. +/// +/// The legacy layout stores `token_name`/`token_symbol` as `[u8; 32]`, and the Solidity encoder +/// gives every `u8` element its own word — hence 64 words of names against 4 words of everything +/// else. `test_legacy_size_matches_the_encoder` pins this constant to the encoder. +pub const INITIAL_SETTINGS_LEGACY_SIZE: usize = 4 + 68 * 32; + +/// Largest payload any accepted creation form occupies, prefix included. +pub const INITIAL_SETTINGS_MAX_SIZE: usize = INITIAL_SETTINGS_LEGACY_SIZE; + #[derive(Default, Debug, PartialEq, Codec)] struct InitialSettingsV1 { pub token_name: TokenNameOrSymbol, @@ -153,6 +194,13 @@ impl InitialSettings { output.into() } + /// Decodes a creation payload, accepting only the exact canonical V1, V2 and legacy forms. + /// + /// Length is matched exactly rather than as a lower bound. The underlying Solidity decoder is + /// happy to stop short of the end of its buffer, so a lower bound would let a creator append + /// arbitrary bytes that decode to the same token — bytes the constructor would then persist as + /// account metadata. Re-encoding the result via [`Self::encode_with_prefix`] is therefore + /// always canonical and never longer than the input. pub fn decode_with_prefix(buf: &[u8]) -> Option { if buf.len() < 4 { return None; @@ -187,7 +235,7 @@ impl InitialSettings { wrapped: Some(settings.wrapped), }) } - _ if buf.len() > INITIAL_SETTINGS_V1_SIZE => { + INITIAL_SETTINGS_LEGACY_SIZE => { // Legacy format uses a different layout and larger payload. let settings = LegacyInitialSettings::decode_with_prefix(buf)?; Some(Self { @@ -358,9 +406,59 @@ pub fn erc20_compute_storage_keys( #[cfg(test)] mod tests { use crate::universal_token::storage::{ - InitialSettings, TokenNameOrSymbol, INITIAL_SETTINGS_V1_SIZE, INITIAL_SETTINGS_V2_SIZE, + InitialSettings, LegacyInitialSettings, TokenNameOrSymbol, INITIAL_SETTINGS_LEGACY_SIZE, + INITIAL_SETTINGS_V1_SIZE, INITIAL_SETTINGS_V2_SIZE, + }; + use alloc::{format, vec::Vec}; + use fluentbase_codec::SolidityABI; + use fluentbase_types::{ + address, bytes::BytesMut, Address, Bytes, B256, U256, UNIVERSAL_TOKEN_MAGIC_BYTES, }; - use fluentbase_types::{address, Address, U256}; + + #[test] + fn test_token_name_boundaries() { + // 32 bytes is the inclusive limit, for ASCII and multibyte alike. + let ascii = "a".repeat(32); + assert_eq!( + TokenNameOrSymbol::try_from_str(&ascii).unwrap().as_str(), + Some(ascii.as_str()) + ); + let multibyte = "😀".repeat(8); + assert_eq!( + TokenNameOrSymbol::try_from_str(&multibyte) + .unwrap() + .as_str(), + Some(multibyte.as_str()) + ); + + // Overlong input is rejected rather than reshaped into a different token. + assert!(TokenNameOrSymbol::try_from_str(&"a".repeat(33)).is_none()); + assert!(TokenNameOrSymbol::try_from_str(&format!("{}é", "a".repeat(31))).is_none()); + } + + #[test] + fn test_token_name_truncation_stays_decodable() { + // The infallible constructor still truncates, but never mid-code-point: a 33-byte + // input whose 32-byte prefix splits `é` must drop the whole character. + let split = format!("{}é", "a".repeat(31)); + assert_eq!(split.len(), 33); + assert_eq!( + TokenNameOrSymbol::from_str(&split).as_str(), + Some("a".repeat(31).as_str()) + ); + assert_eq!( + TokenNameOrSymbol::from_str(&"😀".repeat(9)).as_str(), + Some("😀".repeat(8).as_str()) + ); + } + + #[test] + fn test_token_name_from_word_rejects_malformed() { + // Raw words arrive from calldata and legacy layouts unvalidated. + let mut word = B256::ZERO; + word[0] = 0x80; // lone continuation byte + assert_eq!(TokenNameOrSymbol::from_word(word).as_str(), None); + } #[test] fn test_ops_u256_overflow() { @@ -413,4 +511,170 @@ mod tests { let settings_restored = InitialSettings::decode_with_prefix(settings_vec.as_ref()).unwrap(); assert_eq!(settings, settings_restored); } + + /// Builds a canonical legacy payload for `name`/`symbol` short strings. + fn legacy_payload(name: &str, symbol: &str) -> Bytes { + let mut token_name = [0u8; 32]; + token_name[..name.len()].copy_from_slice(name.as_bytes()); + let mut token_symbol = [0u8; 32]; + token_symbol[..symbol.len()].copy_from_slice(symbol.as_bytes()); + + let settings = LegacyInitialSettings { + token_name, + token_symbol, + decimals: 6, + initial_supply: U256::from(7), + minter: address!("0303000200500020400000040000002000809020"), + pauser: address!("0003000200500000400000040000002000800020"), + }; + let mut payload = BytesMut::new(); + SolidityABI::encode(&settings, &mut payload, 0).unwrap(); + + let mut out = Vec::with_capacity(UNIVERSAL_TOKEN_MAGIC_BYTES.len() + payload.len()); + out.extend_from_slice(&UNIVERSAL_TOKEN_MAGIC_BYTES[..]); + out.extend_from_slice(&payload); + out.into() + } + + #[test] + fn test_legacy_size_matches_the_encoder() { + // Pins `INITIAL_SETTINGS_LEGACY_SIZE` to what the encoder actually produces, so the exact + // length check cannot silently start rejecting every legacy payload. + assert_eq!( + legacy_payload("Legacy", "LGC").len(), + INITIAL_SETTINGS_LEGACY_SIZE + ); + } + + #[test] + fn test_legacy_payload_decodes_to_v1_settings() { + let decoded = + InitialSettings::decode_with_prefix(&legacy_payload("Legacy", "LGC")).unwrap(); + assert_eq!(decoded.token_name.as_str(), Some("Legacy")); + assert_eq!(decoded.token_symbol.as_str(), Some("LGC")); + assert_eq!(decoded.decimals, 6); + assert_eq!(decoded.initial_supply, U256::from(7)); + assert_eq!(decoded.wrapped, None); + + // Legacy carries no wrapped flag, so its canonical form is V1 — an order of magnitude + // smaller than the payload it came from. + assert_eq!(decoded.encode_with_prefix().len(), INITIAL_SETTINGS_V1_SIZE); + } + + fn is_canonical_len(len: usize) -> bool { + matches!( + len, + INITIAL_SETTINGS_V1_SIZE | INITIAL_SETTINGS_V2_SIZE | INITIAL_SETTINGS_LEGACY_SIZE + ) + } + + /// The three payload forms that must decode, and nothing else. + fn canonical_payloads() -> Vec<(&'static str, Bytes)> { + let v1 = InitialSettings { + token_name: TokenNameOrSymbol::from_str("Hello"), + token_symbol: TokenNameOrSymbol::from_str("HLO"), + decimals: 12, + initial_supply: U256::from(2), + minter: address!("0303000200500020400000040000002000809020"), + pauser: Address::ZERO, + wrapped: None, + }; + let v2 = InitialSettings { + wrapped: Some(true), + minter: Address::ZERO, + ..InitialSettings::default() + }; + alloc::vec![ + ("v1", v1.encode_with_prefix()), + ("v2", v2.encode_with_prefix()), + ("legacy", legacy_payload("Legacy", "LGC")), + ] + } + + #[test] + fn test_canonical_payloads_decode() { + for (label, payload) in canonical_payloads() { + assert!( + InitialSettings::decode_with_prefix(&payload).is_some(), + "{label} payload must decode" + ); + } + } + + #[test] + fn test_trailing_bytes_are_rejected() { + for (label, payload) in canonical_payloads() { + for suffix_len in [1usize, 2, 31, 32, 33, 1024] { + // A V1 payload plus exactly one word *is* a canonical V2 payload; that is a + // different form, not an ignored suffix. + if is_canonical_len(payload.len() + suffix_len) { + continue; + } + let mut padded = payload.to_vec(); + padded.extend(core::iter::repeat_n(0u8, suffix_len)); + assert!( + InitialSettings::decode_with_prefix(&padded).is_none(), + "{label} payload with {suffix_len} trailing bytes must be rejected" + ); + } + } + } + + #[test] + fn test_truncated_payloads_are_rejected() { + for (label, payload) in canonical_payloads() { + let truncated = &payload[..payload.len() - 1]; + assert!( + InitialSettings::decode_with_prefix(truncated).is_none(), + "{label} payload missing its last byte must be rejected" + ); + } + } + + #[test] + fn test_only_canonical_lengths_are_accepted() { + // Sweep every length up to and past the legacy form: the accepted set is exactly the + // three canonical sizes, so no length can carry an ignored suffix. + let template = legacy_payload("Legacy", "LGC"); + for len in 0..=INITIAL_SETTINGS_LEGACY_SIZE + 64 { + let mut payload = Vec::with_capacity(len); + payload.extend_from_slice(&template[..len.min(template.len())]); + payload.resize(len, 0); + + let accepted = InitialSettings::decode_with_prefix(&payload).is_some(); + let canonical = is_canonical_len(len); + // Only the legacy length is a real payload here; the shorter canonical lengths are + // truncations of it, so they may or may not decode. What must hold is that no + // non-canonical length ever decodes. + assert!( + !accepted || canonical, + "length {len} is not canonical but decoded" + ); + } + } + + #[test] + fn test_reencoding_a_decoded_payload_is_canonical() { + for (label, payload) in canonical_payloads() { + let decoded = InitialSettings::decode_with_prefix(&payload).unwrap(); + let reencoded = decoded.encode_with_prefix(); + + assert!( + reencoded.len() <= payload.len(), + "{label}: canonical form must never grow" + ); + // Re-encoding is a fixed point: decoding the canonical bytes yields the same settings. + assert_eq!( + InitialSettings::decode_with_prefix(&reencoded).unwrap(), + decoded, + "{label}: canonical form must round-trip" + ); + if label != "legacy" { + assert_eq!( + reencoded, payload, + "{label}: already-canonical input must be preserved byte for byte" + ); + } + } + } } diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 6e1e0ecc0..05cea504d 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -116,10 +116,27 @@ pub const QUADRATIC_DIVISOR: u32 = 512; /// A max rWasm call stack limit pub const CALL_STACK_LIMIT: u32 = 1024; +/// Maximum linear memory, in bytes, that all live contract frames of one transaction may hold +/// at the same time. +/// +/// A suspended frame keeps its entire store — linear memory included — alive until it is resumed +/// or forgotten, so a call chain holds `depth * frame_size` bytes resident at once. Per-frame fuel +/// prices that allocation but cannot bound the sum over frames: at `CALL_STACK_LIMIT` frames of +/// the largest permitted memory it reaches ~64 GiB while costing about half of a 100M gas block. +/// +/// The limit sits above the worst case reachable by ordinary contracts — full `CALL_STACK_LIMIT` +/// depth at the Rust/Wasm toolchain's default 17 initial pages, roughly 1.06 GiB — so no +/// execution that succeeds today begins to fail. The tests in `runtime::executor` pin both ends +/// of that trade-off. +pub const MAX_IN_FLIGHT_MEMORY_BYTES: u64 = 1536 * 1024 * 1024; + /// WASM max code size /// /// This value is temporary for testing purposes, requires recalculation. /// The limit is equal to 2Mb. +/// +/// Scope: this bounds untrusted deployment (`CREATE`/`CREATE2`). Runtime upgrades are exempt by +/// design — see `compile_and_install` in `contracts/runtime-upgrade`. pub const WASM_MAX_CODE_SIZE: usize = 0x100000; #[cfg(feature = "svm")] pub const SVM_MAX_CODE_SIZE: usize = 0x200000; @@ -128,6 +145,9 @@ pub const SVM_MAX_CODE_SIZE: usize = 0x200000; /// /// This limit is required to limit the number of bytes produced after Wasm binary compilation. /// There are several attack vectors on this that produces abnormal amount of instructions. +/// +/// Scope: this bounds compilation reachable by untrusted callers. Runtime upgrades are exempt by +/// design — see `compile_and_install` in `contracts/runtime-upgrade`. pub const RWASM_MAX_CODE_SIZE: usize = 12 * 1024 * 1024; /// WebAssembly magic bytes diff --git a/e2e/src/builtins.rs b/e2e/src/builtins.rs index 62d32b4e5..b6c68c697 100644 --- a/e2e/src/builtins.rs +++ b/e2e/src/builtins.rs @@ -82,8 +82,11 @@ fn test_keccak_builtin() { "#; let gas = run_twice_and_find_gas_difference(main, 0); let words = (123000 + 31) / 32; - let expected_fuel = - KECCAK_BASE_FUEL_COST + KECCAK_WORD_FUEL_COST * words + 3 * BASE_FUEL_COST + CALL_FUEL_COST; + let expected_fuel = 512 * FUEL_DENOM_RATE as u32 + + KECCAK_BASE_FUEL_COST + + KECCAK_WORD_FUEL_COST * words + + 3 * BASE_FUEL_COST + + CALL_FUEL_COST; assert_eq!(gas, fuel_to_gas(expected_fuel)); } @@ -96,8 +99,11 @@ fn test_write_builtin() { "#; let gas = run_twice_and_find_gas_difference(main, 0); let words = (123000 + 31) / 32; - let expected_fuel = - COPY_BASE_FUEL_COST + COPY_WORD_FUEL_COST * words + 2 * BASE_FUEL_COST + CALL_FUEL_COST; + let expected_fuel = 512 * FUEL_DENOM_RATE as u32 + + COPY_BASE_FUEL_COST + + COPY_WORD_FUEL_COST * words + + 2 * BASE_FUEL_COST + + CALL_FUEL_COST; assert_eq!(gas, fuel_to_gas(expected_fuel)); } @@ -140,7 +146,8 @@ fn test_read_builtin() { let gas_offset = fuel_to_gas(30_000_000); let gas = run_twice_and_find_gas_difference(main, 1_000).saturating_sub(gas_offset); let words = (800 + 31) / 32; - let expected_fuel = COPY_BASE_FUEL_COST + let expected_fuel = 512 * FUEL_DENOM_RATE as u32 + + COPY_BASE_FUEL_COST + COPY_WORD_FUEL_COST * words + CHARGE_FUEL_BASE_COST + 4 * BASE_FUEL_COST @@ -157,7 +164,8 @@ fn test_debug_log_builtin() { "#; let gas = run_twice_and_find_gas_difference(main, 0); let words = (123000 + 31) / 32; - let expected_fuel = DEBUG_LOG_BASE_FUEL_COST + let expected_fuel = 512 * FUEL_DENOM_RATE as u32 + + DEBUG_LOG_BASE_FUEL_COST + DEBUG_LOG_WORD_FUEL_COST * words + 2 * BASE_FUEL_COST + CALL_FUEL_COST; @@ -173,7 +181,8 @@ fn test_output_size_builtin() { let gas = run_twice_and_find_gas_difference(main, 0); // OUTPUT_SIZE syscall uses LOW_FUEL_COST - let expected_fuel = CALL_FUEL_COST + BASE_FUEL_COST + STATE_FUEL_COST; + let expected_fuel = + 512 * FUEL_DENOM_RATE as u32 + CALL_FUEL_COST + BASE_FUEL_COST + STATE_FUEL_COST; assert_eq!(gas, fuel_to_gas(expected_fuel)); } @@ -185,7 +194,8 @@ fn test_state_builtin() { "#; let gas = run_twice_and_find_gas_difference(main, 0); // STATE syscall uses LOW_FUEL_COST - let expected_fuel = CALL_FUEL_COST + BASE_FUEL_COST + STATE_FUEL_COST; + let expected_fuel = + 512 * FUEL_DENOM_RATE as u32 + CALL_FUEL_COST + BASE_FUEL_COST + STATE_FUEL_COST; assert_eq!(gas, fuel_to_gas(expected_fuel)); } @@ -197,7 +207,8 @@ fn test_fuel_builtin() { "#; let gas = run_twice_and_find_gas_difference(main, 0); // FUEL syscall uses LOW_FUEL_COST - let expected_fuel = CALL_FUEL_COST + BASE_FUEL_COST + STATE_FUEL_COST; + let expected_fuel = + 512 * FUEL_DENOM_RATE as u32 + CALL_FUEL_COST + BASE_FUEL_COST + STATE_FUEL_COST; assert_eq!(gas, fuel_to_gas(expected_fuel)); } @@ -218,7 +229,8 @@ fn test_charge_fuel_builtin() { call $_charge_fuel "#; let gas = run_twice_and_find_gas_difference(main, 0); - let expected_fuel = 3 * (CALL_FUEL_COST + CHARGE_FUEL_BASE_COST + BASE_FUEL_COST); + let expected_fuel = 512 * FUEL_DENOM_RATE as u32 + + 3 * (CALL_FUEL_COST + CHARGE_FUEL_BASE_COST + BASE_FUEL_COST); assert_eq!(gas, fuel_to_gas(expected_fuel)); // Call with argument - shows that argument adds to the base costs @@ -227,7 +239,11 @@ fn test_charge_fuel_builtin() { call $_charge_fuel "#; let gas = run_twice_and_find_gas_difference(main, 0); - let expected_fuel = CALL_FUEL_COST + CHARGE_FUEL_BASE_COST + BASE_FUEL_COST + 500; + let expected_fuel = 512 * FUEL_DENOM_RATE as u32 + + CALL_FUEL_COST + + CHARGE_FUEL_BASE_COST + + BASE_FUEL_COST + + 500; assert_eq!(gas, fuel_to_gas(expected_fuel)); } @@ -239,5 +255,8 @@ fn test_exit_builtin() { "#; let gas = run_twice_and_find_gas_difference(main, 0); // Exit doesn't consume fuel, only the call instruction - assert_eq!(gas, fuel_to_gas(BASE_FUEL_COST + CALL_FUEL_COST)); + assert_eq!( + gas, + fuel_to_gas(512 * FUEL_DENOM_RATE as u32 + BASE_FUEL_COST + CALL_FUEL_COST) + ); } diff --git a/e2e/src/ddos.rs b/e2e/src/ddos.rs index 36258079c..657e67f41 100644 --- a/e2e/src/ddos.rs +++ b/e2e/src/ddos.rs @@ -1,5 +1,7 @@ use crate::EvmTestingContextWithGenesis; -use fluentbase_sdk::{calc_create_address, Address, Bytes}; +use fluentbase_sdk::{ + calc_create_address, Address, Bytes, COPY_BASE_FUEL_COST, COPY_WORD_FUEL_COST, FUEL_DENOM_RATE, +}; use fluentbase_testing::{EvmTestingContext, TxBuilder}; use wat::parse_str; @@ -48,6 +50,31 @@ const EXEC_BALANCE_DOS_WAT: &str = r#" ) "#; +const REPEATED_WRITE_OUTPUT_WAT: &str = r#" + (module + (import "fluentbase_v1preview" "_write" (func $_write (param i32 i32))) + (memory (export "memory") 16) ;; 1 MiB + (func (export "deploy")) + (func (export "main") (local $remaining i32) + i32.const 64 + local.set $remaining + + loop $write + ;; Reuse the same valid 1 MiB guest-memory range for every append. + i32.const 0 + i32.const 1048576 + call $_write + + local.get $remaining + i32.const 1 + i32.sub + local.tee $remaining + br_if $write + end + ) + ) +"#; + fn deploy_exec_balance_contract(ctx: &mut EvmTestingContext) -> Address { let wasm = parse_str(EXEC_BALANCE_DOS_WAT).expect("invalid wat"); let deployer = Address::ZERO; @@ -95,3 +122,63 @@ fn ddos_balance_rejects_huge_input_without_memory_copy() { let large = call_with_len(&mut ctx, contract, LARGE_LEN); assert!(large.is_halt(), "large call should halt: {large:?}"); } + +#[test] +fn ddos_repeated_write_accumulates_output_under_block_gas_limit() { + const CHUNK_BYTES: usize = 1024 * 1024; + const WRITE_COUNT: usize = 64; + const MAX_BLOCK_GAS: u64 = 100_000_000; + + let wasm = parse_str(REPEATED_WRITE_OUTPUT_WAT).expect("invalid repeated-write wat"); + let deployer = Address::ZERO; + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let deploy_result = TxBuilder::create(&mut ctx, deployer, wasm.into()) + .gas_price(0) + .gas_limit(1_000_000) + .exec(); + assert!( + deploy_result.is_success(), + "failed to deploy repeated-write contract: {deploy_result:?}" + ); + + let contract = calc_create_address(&deployer, 0); + let result = TxBuilder::call(&mut ctx, contract) + .caller(deployer) + .gas_price(0) + .gas_limit(MAX_BLOCK_GAS) + .exec(); + assert!( + result.is_success(), + "repeated output writes unexpectedly failed after consuming {} gas", + result.tx_gas_used() + ); + + let output_len = result + .output() + .expect("successful call must return output") + .len(); + assert_eq!(output_len, CHUNK_BYTES * WRITE_COUNT); + + // Fuel is charged per `_write`, but it does not bound the aggregate output buffer. This + // execution reuses 1 MiB of guest memory to append 64 MiB to the host output Vec. + let gas_used = result.tx_gas_used(); + let words_per_write = (CHUNK_BYTES as u64).div_ceil(32); + let gas_per_write = (COPY_BASE_FUEL_COST as u64 + COPY_WORD_FUEL_COST as u64 * words_per_write) + .div_ceil(FUEL_DENOM_RATE); + assert!( + gas_used > gas_per_write * WRITE_COUNT as u64, + "transaction gas must include the measured repeated-write cost" + ); + assert!( + gas_used < 7_000_000, + "64 MiB output used too much gas: {gas_used}" + ); + + // Scaling the observed full-transaction cost (including fixed overhead) leaves enough block + // gas for over 900 MiB of output, so the per-call fuel bound is not an aggregate memory bound. + let projected_output = output_len as u128 * MAX_BLOCK_GAS as u128 / gas_used as u128; + assert!( + projected_output >= 900 * CHUNK_BYTES as u128, + "{MAX_BLOCK_GAS} gas permits only {projected_output} output bytes" + ); +} diff --git a/e2e/src/eip7951.rs b/e2e/src/eip7951.rs new file mode 100644 index 000000000..a4832f9c1 --- /dev/null +++ b/e2e/src/eip7951.rs @@ -0,0 +1,29 @@ +use crate::EvmTestingContextWithGenesis; +use fluentbase_sdk::{Bytes, PRECOMPILE_EIP7951}; +use fluentbase_testing::{EvmTestingContext, TxBuilder}; +use hex_literal::hex; +use revm::{ + interpreter::gas::calculate_initial_tx_gas, + primitives::{hardfork::SpecId, Address, B256, U256}, +}; + +const EIP7951_VERIFY_GAS: u64 = 6_900; + +#[test] +fn eip7951_genesis_route_charges_osaka_gas() { + let input = Bytes::from_static(&hex!("4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4da73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d604aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff37618b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e")); + let initial_gas = + calculate_initial_tx_gas(SpecId::PRAGUE, &input, false, 0, 0, 0).initial_total_gas; + let mut ctx = EvmTestingContext::default().with_full_genesis(); + ctx.add_balance(Address::ZERO, U256::from(100_000)); + + let result = TxBuilder::call(&mut ctx, PRECOMPILE_EIP7951) + .caller(Address::ZERO) + .input(input) + .gas_limit(100_000) + .exec(); + + assert!(result.is_success(), "execution failed: {result:?}"); + assert_eq!(result.output(), Some(&B256::with_last_byte(1).into())); + assert_eq!(result.tx_gas_used() - initial_gas, EIP7951_VERIFY_GAS); +} diff --git a/e2e/src/evm.rs b/e2e/src/evm.rs index 5bac9c343..17e910b89 100644 --- a/e2e/src/evm.rs +++ b/e2e/src/evm.rs @@ -3,7 +3,7 @@ use alloy_sol_types::{sol, SolCall}; use core::str::from_utf8; use fluentbase_contracts::{FLUENTBASE_EXAMPLES_ERC20, FLUENTBASE_EXAMPLES_GREETING}; use fluentbase_sdk::{ - address, bytes, calc_create_address, constructor::encode_constructor_params, Address, + address, bytes, calc_create_address, constructor::encode_constructor_params, Address, Bytes, PRECOMPILE_BLAKE2F, PRECOMPILE_CREATE2_FACTORY, PRECOMPILE_SECP256K1_RECOVER, U256, }; use fluentbase_testing::{try_print_utf8_error, EvmTestingContext, TxBuilder}; @@ -630,3 +630,159 @@ fn test_create2_factory() { let output = U256::from_be_slice(result.output().unwrap().as_ref()); assert_eq!(output, U256::from(123)); } + +/// Offset that cannot be narrowed to a host `usize`. Canonical EVM ignores it entirely when the +/// paired length is zero, so every opcode below must succeed instead of halting. +const UNREPRESENTABLE_OFFSET: U256 = U256::from_limbs([0, 0, 0, 1 << 63]); + +fn push_u256(bytecode: &mut Vec, value: U256) { + bytecode.push(opcode::PUSH32); + bytecode.extend_from_slice(&value.to_be_bytes::<32>()); +} + +/// `EXTCODECOPY` with the given destination offset and length, copying from `callee`. +fn extcodecopy_bytecode(callee: Address, memory_offset: U256, len: U256) -> Vec { + let mut bytecode = Vec::new(); + push_u256(&mut bytecode, len); + bytecode.push(opcode::PUSH0); // code offset + push_u256(&mut bytecode, memory_offset); + bytecode.push(opcode::PUSH20); + bytecode.extend_from_slice(callee.as_slice()); + bytecode.push(opcode::EXTCODECOPY); + bytecode.push(opcode::STOP); + bytecode +} + +/// A call opcode with the given output range. `CALL`/`CALLCODE` also take a (zero) value argument. +fn call_bytecode(op: u8, callee: Address, out_offset: U256, out_len: U256) -> Vec { + let mut bytecode = Vec::new(); + push_u256(&mut bytecode, out_len); + push_u256(&mut bytecode, out_offset); + bytecode.push(opcode::PUSH0); // args length + bytecode.push(opcode::PUSH0); // args offset + if op == opcode::CALL || op == opcode::CALLCODE { + bytecode.push(opcode::PUSH0); // value + } + bytecode.push(opcode::PUSH20); + bytecode.extend_from_slice(callee.as_slice()); + bytecode.push(opcode::PUSH2); // forwarded gas + bytecode.extend_from_slice(&[0xff, 0xff]); + bytecode.push(op); + bytecode.push(opcode::STOP); + bytecode +} + +/// Deploys `bytecode` at a fresh address alongside a trivial callee, and runs it. +fn run_bytecode(bytecode: Vec) -> bool { + const CALLER_ADDRESS: Address = Address::repeat_byte(0x11); + const CONTRACT_ADDRESS: Address = Address::repeat_byte(0x22); + let mut ctx = EvmTestingContext::default().with_full_genesis(); + ctx.add_evm_contract(CALLEE_ADDRESS, [opcode::STOP]); + ctx.add_evm_contract(CONTRACT_ADDRESS, bytecode); + let result = ctx.call_evm_tx( + CALLER_ADDRESS, + CONTRACT_ADDRESS, + Bytes::new(), + Some(1_000_000), + None, + ); + println!("{:?}", result); + result.is_success() +} + +/// Callee used by the zero-length output tests; holds a non-empty bytecode so `EXTCODECOPY` has +/// something it could copy if the length were not zero. +const CALLEE_ADDRESS: Address = Address::repeat_byte(0x33); + +#[test] +fn test_zero_length_extcodecopy_ignores_unrepresentable_offset() { + assert!( + run_bytecode(extcodecopy_bytecode( + CALLEE_ADDRESS, + UNREPRESENTABLE_OFFSET, + U256::ZERO + )), + "EXTCODECOPY with zero length must ignore the destination offset" + ); + // A representable offset with zero length is the same no-op. + assert!(run_bytecode(extcodecopy_bytecode( + CALLEE_ADDRESS, + U256::from(64), + U256::ZERO + ))); +} + +#[test] +fn test_nonzero_length_extcodecopy_rejects_unrepresentable_offset() { + assert!( + !run_bytecode(extcodecopy_bytecode( + CALLEE_ADDRESS, + UNREPRESENTABLE_OFFSET, + U256::ONE + )), + "EXTCODECOPY with non-zero length must still reject an unrepresentable offset" + ); + assert!( + !run_bytecode(extcodecopy_bytecode( + CALLEE_ADDRESS, + U256::ZERO, + UNREPRESENTABLE_OFFSET + )), + "EXTCODECOPY must reject an unrepresentable length" + ); +} + +#[test] +fn test_zero_length_call_output_ignores_unrepresentable_offset() { + for op in [ + opcode::CALL, + opcode::CALLCODE, + opcode::DELEGATECALL, + opcode::STATICCALL, + ] { + assert!( + run_bytecode(call_bytecode( + op, + CALLEE_ADDRESS, + UNREPRESENTABLE_OFFSET, + U256::ZERO + )), + "opcode {op:#04x} with a zero-length output range must ignore the output offset" + ); + assert!(run_bytecode(call_bytecode( + op, + CALLEE_ADDRESS, + U256::from(64), + U256::ZERO + ))); + } +} + +#[test] +fn test_nonzero_length_call_output_rejects_unrepresentable_offset() { + for op in [ + opcode::CALL, + opcode::CALLCODE, + opcode::DELEGATECALL, + opcode::STATICCALL, + ] { + assert!( + !run_bytecode(call_bytecode( + op, + CALLEE_ADDRESS, + UNREPRESENTABLE_OFFSET, + U256::ONE + )), + "opcode {op:#04x} must still reject an unrepresentable output offset" + ); + assert!( + !run_bytecode(call_bytecode( + op, + CALLEE_ADDRESS, + U256::ZERO, + UNREPRESENTABLE_OFFSET + )), + "opcode {op:#04x} must reject an unrepresentable output length" + ); + } +} diff --git a/e2e/src/fee_manager.rs b/e2e/src/fee_manager.rs index f12237852..c7d4056df 100644 --- a/e2e/src/fee_manager.rs +++ b/e2e/src/fee_manager.rs @@ -1,7 +1,9 @@ use crate::EvmTestingContextWithGenesis; use alloy_sol_types::{sol, SolCall}; use fluentbase_codec::SolidityABI; -use fluentbase_sdk::{address, Address, DEFAULT_FEE_MANAGER_AUTH, PRECOMPILE_FEE_MANAGER, U256}; +use fluentbase_sdk::{ + address, Address, DEFAULT_FEE_MANAGER_AUTH, PRECOMPILE_FEE_MANAGER, SYSTEM_ADDRESS, U256, +}; use fluentbase_testing::EvmTestingContext; #[test] @@ -74,6 +76,129 @@ fn test_fee_manager_change_owner_unauthorized() { assert!(!result.is_success()); } +sol! { + function owner() external view returns (address); + function changeOwner(address new_owner) external; + function renounceOwnership() external; + function withdraw(address recipient) external; +} + +fn read_owner(ctx: &mut EvmTestingContext) -> Address { + let result = ctx.call_evm_tx( + Address::ZERO, + PRECOMPILE_FEE_MANAGER, + ownerCall {}.abi_encode().into(), + None, + None, + ); + assert!(result.is_success()); + ownerCall::abi_decode_returns_validate(result.output().unwrap()).unwrap() +} + +fn change_owner(ctx: &mut EvmTestingContext, caller: Address, new_owner: Address) -> bool { + ctx.call_evm_tx( + caller, + PRECOMPILE_FEE_MANAGER, + changeOwnerCall { new_owner }.abi_encode().into(), + None, + None, + ) + .is_success() +} + +fn withdraw(ctx: &mut EvmTestingContext, caller: Address, recipient: Address) -> bool { + ctx.call_evm_tx( + caller, + PRECOMPILE_FEE_MANAGER, + withdrawCall { recipient }.abi_encode().into(), + None, + None, + ) + .is_success() +} + +/// Zero is the empty-slot sentinel that `owner()` maps to the genesis bootstrap key, so accepting +/// it as a transfer target would silently reactivate the retired launch authority. +#[test] +fn test_fee_manager_change_owner_to_zero_is_rejected() { + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let governance = address!("1234567890123456789012345678901234567890"); + + // Bootstrap state: an unset slot resolves to the genesis authority. + assert_eq!(read_owner(&mut ctx), DEFAULT_FEE_MANAGER_AUTH); + + // Nonzero handoff retires the bootstrap key. + assert!(change_owner(&mut ctx, DEFAULT_FEE_MANAGER_AUTH, governance)); + assert_eq!(read_owner(&mut ctx), governance); + + // The zero transfer must revert before touching storage. + assert!(!change_owner(&mut ctx, governance, Address::ZERO)); + assert_eq!( + read_owner(&mut ctx), + governance, + "zero transfer restored the bootstrap authority" + ); + + // The retired genesis key stays powerless, and the current owner keeps its authority. + assert!(!change_owner( + &mut ctx, + DEFAULT_FEE_MANAGER_AUTH, + DEFAULT_FEE_MANAGER_AUTH + )); + assert_eq!(read_owner(&mut ctx), governance); + assert!(change_owner(&mut ctx, governance, governance)); +} + +/// Withdrawal is the sink the retired key must not regain. +#[test] +fn test_fee_manager_withdraw_authority_survives_rejected_zero_transfer() { + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let governance = address!("1234567890123456789012345678901234567890"); + let recipient = Address::repeat_byte(0x11); + let amount = U256::from(1000); + + assert!(change_owner(&mut ctx, DEFAULT_FEE_MANAGER_AUTH, governance)); + assert!(!change_owner(&mut ctx, governance, Address::ZERO)); + + ctx.add_balance(PRECOMPILE_FEE_MANAGER, amount); + + // The bootstrap key cannot drain fees after the handoff. + assert!(!withdraw(&mut ctx, DEFAULT_FEE_MANAGER_AUTH, recipient)); + assert_eq!(ctx.get_balance(PRECOMPILE_FEE_MANAGER), amount); + + // The real owner still can. + assert!(withdraw(&mut ctx, governance, recipient)); + assert_eq!(ctx.get_balance(recipient), amount); +} + +/// Renunciation stays the explicit fork-only exit and is not weakened by the zero-address guard. +#[test] +fn test_fee_manager_renounce_ownership_is_still_terminal() { + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let governance = address!("1234567890123456789012345678901234567890"); + + assert!(change_owner(&mut ctx, DEFAULT_FEE_MANAGER_AUTH, governance)); + + let result = ctx.call_evm_tx( + governance, + PRECOMPILE_FEE_MANAGER, + renounceOwnershipCall {}.abi_encode().into(), + None, + None, + ); + assert!(result.is_success()); + assert_eq!(read_owner(&mut ctx), SYSTEM_ADDRESS); + + // Neither the previous owner nor the retired bootstrap key can take it back. + assert!(!change_owner(&mut ctx, governance, governance)); + assert!(!change_owner( + &mut ctx, + DEFAULT_FEE_MANAGER_AUTH, + DEFAULT_FEE_MANAGER_AUTH + )); + assert_eq!(read_owner(&mut ctx), SYSTEM_ADDRESS); +} + #[test] fn test_fee_manager_withdraw() { let mut ctx = EvmTestingContext::default().with_full_genesis(); diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index 15b200494..dac7a2241 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -38,6 +38,8 @@ mod deployer; #[cfg(test)] mod eip2935; #[cfg(test)] +mod eip7951; +#[cfg(test)] mod erc2612; #[cfg(test)] mod evm; diff --git a/e2e/src/update_account.rs b/e2e/src/update_account.rs index 2b048f049..44e662d0d 100644 --- a/e2e/src/update_account.rs +++ b/e2e/src/update_account.rs @@ -1,16 +1,50 @@ use crate::EvmTestingContextWithGenesis; +use alloy_sol_types::{sol, SolCall, SolEvent}; use bytes::BytesMut; use fluentbase_codec::SolidityABI; use fluentbase_genesis::GENESIS_CONTRACTS_BY_ADDRESS; use fluentbase_sdk::{ address, bytes, compile_rwasm_maybe_system, crypto::crypto_keccak256, Address, Bytes, B256, - DEFAULT_UPDATE_GENESIS_AUTH, PRECOMPILE_EVM_RUNTIME, PRECOMPILE_RUNTIME_UPGRADE, U256, - UPDATE_GENESIS_PREFIX, + DEFAULT_UPDATE_GENESIS_AUTH, PRECOMPILE_EVM_RUNTIME, PRECOMPILE_RIPEMD160, + PRECOMPILE_RUNTIME_UPGRADE, PRECOMPILE_WEBAUTHN_VERIFIER, U256, UPDATE_GENESIS_PREFIX, }; use fluentbase_testing::EvmTestingContext; use hex_literal::hex; use revm::context::result::ExecutionResult; +sol! { + event RuntimeUpgraded( + address indexed target_address, + bytes32 indexed genesis_hash, + string genesis_version, + bytes32 code_hash + ); + + function upgradeTo( + address target_address, + uint256 genesis_hash, + string genesis_version, + bytes wasm_bytecode + ); + + function upgradeEvmTo( + address target_address, + uint256 genesis_hash, + string genesis_version, + bytes evm_bytecode + ); + + function planUpgrade( + uint256 genesis_hash, + string genesis_version, + address[] target_addresses, + bytes32[] wasm_code_hashes, + address updater + ); + + function upgradeToPlanned(address target_address, bytes wasm_bytecode); +} + #[test] fn test_upgrade_solidity_contract_preserves_storage() { let mut ctx = EvmTestingContext::default().with_full_genesis(); @@ -256,3 +290,245 @@ fn test_cant_upgrade_from_incorrect_address() { println!("{:?}", result); assert!(!result.is_success()); } + +/// A canonical EVM precompile that Fluent implements as rWasm in state. EVM-facing code queries for +/// these addresses are masked to empty by design, which is why upgrade events must not source their +/// artifact hash from `EXTCODEHASH`. +const CANONICAL_PRECOMPILE_TARGET: Address = PRECOMPILE_RIPEMD160; +/// A Fluent system address, outside the canonical precompile range and therefore unmasked. +const FLUENT_TARGET: Address = PRECOMPILE_WEBAUTHN_VERIFIER; +const UPDATER_ADDRESS: Address = address!("0x8888888888888888888888888888888888888888"); + +/// A minimal WASM runtime, valid enough to compile but small enough to keep upgrade tests cheap. +fn upgrade_wasm_module() -> Bytes { + wat::parse_str( + r#" +(module + (memory (export "memory") 1) + (func (export "main") (param i32 i32) (result i32) + unreachable + ) + (func (export "deploy") + unreachable + ) +) + "#, + ) + .unwrap() + .into() +} + +/// The rWasm bytes an upgrade of `target_address` installs, compiled here rather than read back out +/// of state, so the expected hash is derived independently of the contract under test. +fn installed_rwasm_bytecode(target_address: Address, wasm_module: &Bytes) -> Vec { + compile_rwasm_maybe_system(&target_address, wasm_module) + .unwrap() + .rwasm_module + .serialize() +} + +/// Decodes the single `RuntimeUpgraded` log of a successful upgrade with a standard Solidity log +/// decoder (`alloy-sol-types`), not with the encoder the contract itself uses. +fn decode_runtime_upgraded(logs: &[revm::primitives::Log]) -> RuntimeUpgraded { + let log = logs + .iter() + .find(|log| log.topics().first() == Some(&RuntimeUpgraded::SIGNATURE_HASH)) + .expect("no RuntimeUpgraded event emitted"); + RuntimeUpgraded::decode_raw_log(log.topics().iter().copied(), &log.data.data) + .expect("failed to decode RuntimeUpgraded") +} + +/// Reads `EXTCODEHASH` the way an ordinary EVM caller would — through deployed EVM bytecode — so +/// the precompile masking rules apply. +fn evm_ext_code_hash(ctx: &mut EvmTestingContext, address: Address) -> B256 { + const DEPLOYER_ADDRESS: Address = address!("0x9999999999999999999999999999999999999999"); + // PUSH20
; EXTCODEHASH; PUSH0; MSTORE; PUSH1 0x20; PUSH0; RETURN + let mut runtime = vec![0x73u8]; + runtime.extend_from_slice(address.as_slice()); + runtime.extend_from_slice(&[0x3f, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]); + let runtime_len = runtime.len() as u8; + // Copy the runtime tail (12 bytes in) out of the init code and return it. + let mut init_bytecode = vec![ + 0x60, + runtime_len, + 0x60, + 0x0c, + 0x60, + 0x00, + 0x39, + 0x60, + runtime_len, + 0x60, + 0x00, + 0xf3, + ]; + init_bytecode.extend_from_slice(&runtime); + + let (probe_address, _) = ctx.deploy_evm_tx_with_gas(DEPLOYER_ADDRESS, init_bytecode.into()); + let result = ctx.call_evm_tx(DEPLOYER_ADDRESS, probe_address, Bytes::new(), None, None); + assert!(result.is_success(), "{result:?}"); + B256::from_slice(result.output().unwrap()) +} + +/// Upgrades `target_address` with `upgradeTo` and returns the emitted event plus the artifact hash +/// computed independently from the submitted WASM. +fn upgrade_wasm_runtime( + ctx: &mut EvmTestingContext, + target_address: Address, +) -> (RuntimeUpgraded, B256) { + let wasm_module = upgrade_wasm_module(); + let input = upgradeToCall { + target_address, + genesis_hash: U256::ZERO, + genesis_version: "v1.0.1".to_string(), + wasm_bytecode: wasm_module.clone(), + } + .abi_encode(); + + let result = ctx.call_evm_tx( + DEFAULT_UPDATE_GENESIS_AUTH, + PRECOMPILE_RUNTIME_UPGRADE, + input.into(), + None, + None, + ); + assert!(result.is_success(), "{result:?}"); + + let installed = installed_rwasm_bytecode(target_address, &wasm_module); + assert_eq!( + ctx.get_code(target_address) + .unwrap() + .original_bytes() + .as_ref(), + &installed, + "installed bytecode differs from the compiled artifact" + ); + + ( + decode_runtime_upgraded(result.logs()), + crypto_keccak256(&installed), + ) +} + +#[test] +fn test_wasm_upgrade_emits_installed_code_hash_for_canonical_precompile() { + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let (event, expected_code_hash) = upgrade_wasm_runtime(&mut ctx, CANONICAL_PRECOMPILE_TARGET); + + assert_eq!(event.target_address, CANONICAL_PRECOMPILE_TARGET); + assert_ne!( + event.code_hash, + B256::ZERO, + "canonical precompile upgrade emitted a zero artifact hash" + ); + assert_eq!(event.code_hash, expected_code_hash); + + // The masking that broke the event must stay in place for ordinary EVM callers. + assert_eq!( + evm_ext_code_hash(&mut ctx, CANONICAL_PRECOMPILE_TARGET), + B256::ZERO, + "EXTCODEHASH stopped masking a canonical precompile" + ); +} + +#[test] +fn test_wasm_upgrade_emits_installed_code_hash_for_fluent_target() { + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let (event, expected_code_hash) = upgrade_wasm_runtime(&mut ctx, FLUENT_TARGET); + + assert_eq!(event.target_address, FLUENT_TARGET); + assert_ne!(event.code_hash, B256::ZERO); + assert_eq!(event.code_hash, expected_code_hash); + // Unmasked targets keep reporting the same hash through the public EVM query. + assert_eq!( + evm_ext_code_hash(&mut ctx, FLUENT_TARGET), + expected_code_hash + ); +} + +#[test] +fn test_planned_upgrade_emits_installed_code_hash_for_canonical_precompile() { + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let target_address = CANONICAL_PRECOMPILE_TARGET; + let wasm_module = upgrade_wasm_module(); + + let plan_input = planUpgradeCall { + genesis_hash: U256::ZERO, + genesis_version: "v1.0.1".to_string(), + target_addresses: vec![target_address], + wasm_code_hashes: vec![crypto_keccak256(wasm_module.as_ref())], + updater: UPDATER_ADDRESS, + } + .abi_encode(); + let result = ctx.call_evm_tx( + DEFAULT_UPDATE_GENESIS_AUTH, + PRECOMPILE_RUNTIME_UPGRADE, + plan_input.into(), + None, + None, + ); + assert!(result.is_success(), "{result:?}"); + + let upgrade_input = upgradeToPlannedCall { + target_address, + wasm_bytecode: wasm_module.clone(), + } + .abi_encode(); + let result = ctx.call_evm_tx( + UPDATER_ADDRESS, + PRECOMPILE_RUNTIME_UPGRADE, + upgrade_input.into(), + None, + None, + ); + assert!(result.is_success(), "{result:?}"); + + let expected_code_hash = + crypto_keccak256(&installed_rwasm_bytecode(target_address, &wasm_module)); + let event = decode_runtime_upgraded(result.logs()); + assert_eq!(event.target_address, target_address); + assert_ne!( + event.code_hash, + B256::ZERO, + "planned upgrade emitted a zero artifact hash" + ); + assert_eq!(event.code_hash, expected_code_hash); +} + +#[test] +fn test_evm_upgrade_emits_installed_code_hash_for_canonical_precompile() { + let mut ctx = EvmTestingContext::default().with_full_genesis(); + let target_address = CANONICAL_PRECOMPILE_TARGET; + let evm_runtime = bytes!("60005460005260206000f3"); + + let input = upgradeEvmToCall { + target_address, + genesis_hash: U256::ZERO, + genesis_version: "v1.0.1".to_string(), + evm_bytecode: evm_runtime.clone(), + } + .abi_encode(); + let result = ctx.call_evm_tx( + DEFAULT_UPDATE_GENESIS_AUTH, + PRECOMPILE_RUNTIME_UPGRADE, + input.into(), + None, + None, + ); + assert!(result.is_success(), "{result:?}"); + + let event = decode_runtime_upgraded(result.logs()); + assert_eq!(event.target_address, target_address); + assert_ne!( + event.code_hash, + B256::ZERO, + "canonical precompile EVM upgrade emitted a zero artifact hash" + ); + assert_eq!(event.code_hash, crypto_keccak256(evm_runtime.as_ref())); + + assert_eq!( + evm_ext_code_hash(&mut ctx, target_address), + B256::ZERO, + "EXTCODEHASH stopped masking a canonical precompile" + ); +}