diff --git a/Cargo.lock b/Cargo.lock index 9da950c7c619b..45610f03b00b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1183,7 +1183,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1207,7 +1207,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3055,7 +3055,7 @@ dependencies = [ "terminfo", "thiserror 2.0.18", "which", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3214,7 +3214,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4276,7 +4276,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4560,7 +4560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6895,7 +6895,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7689,7 +7689,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9369,7 +9369,7 @@ dependencies = [ [[package]] name = "reth-chainspec" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-chains", "alloy-consensus", @@ -9449,7 +9449,7 @@ dependencies = [ [[package]] name = "reth-consensus" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eip7928 0.4.3", @@ -9463,7 +9463,7 @@ dependencies = [ [[package]] name = "reth-consensus-common" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9476,7 +9476,7 @@ dependencies = [ [[package]] name = "reth-db-api" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -9500,7 +9500,7 @@ dependencies = [ [[package]] name = "reth-db-models" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-eips", "alloy-primitives", @@ -9514,7 +9514,7 @@ dependencies = [ [[package]] name = "reth-ethereum-consensus" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9530,7 +9530,7 @@ dependencies = [ [[package]] name = "reth-ethereum-forks" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-eip2124", "alloy-hardforks", @@ -9543,7 +9543,7 @@ dependencies = [ [[package]] name = "reth-ethereum-primitives" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9557,7 +9557,7 @@ dependencies = [ [[package]] name = "reth-evm" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eip7928 0.4.3", @@ -9580,7 +9580,7 @@ dependencies = [ [[package]] name = "reth-evm-ethereum" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9600,7 +9600,7 @@ dependencies = [ [[package]] name = "reth-execution-errors" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-evm", "alloy-primitives", @@ -9613,7 +9613,7 @@ dependencies = [ [[package]] name = "reth-execution-types" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9632,7 +9632,7 @@ dependencies = [ [[package]] name = "reth-network-peers" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -9699,7 +9699,7 @@ dependencies = [ [[package]] name = "reth-prune-types" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-primitives", "derive_more", @@ -9714,7 +9714,7 @@ dependencies = [ [[package]] name = "reth-revm" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -9727,7 +9727,7 @@ dependencies = [ [[package]] name = "reth-rpc-convert" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-evm", @@ -9777,7 +9777,7 @@ dependencies = [ [[package]] name = "reth-stages-types" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-primitives", "bytes", @@ -9790,7 +9790,7 @@ dependencies = [ [[package]] name = "reth-static-file-types" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-primitives", "derive_more", @@ -9804,7 +9804,7 @@ dependencies = [ [[package]] name = "reth-storage-api" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-eip7928 0.4.3", @@ -9829,7 +9829,7 @@ dependencies = [ [[package]] name = "reth-storage-errors" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-eips", "alloy-primitives", @@ -9847,7 +9847,7 @@ dependencies = [ [[package]] name = "reth-tokio-util" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "tokio", "tokio-stream", @@ -9857,7 +9857,7 @@ dependencies = [ [[package]] name = "reth-trie-common" version = "2.3.0" -source = "git+https://github.com/paradigmxyz/reth?rev=fa3859e#fa3859e6ef44cb18178b9bc0148ea9a3a1280765" +source = "git+https://github.com/paradigmxyz/reth?rev=f955d2907c9b51f101ec93848bce36101a1ee7e3#f955d2907c9b51f101ec93848bce36101a1ee7e3" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -10383,7 +10383,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10442,7 +10442,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11167,7 +11167,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11716,13 +11716,13 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "tempo-alloy" version = "1.8.1" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy-consensus", "alloy-contract", @@ -11755,7 +11755,7 @@ dependencies = [ [[package]] name = "tempo-chainspec" version = "1.8.2" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy-eips", "alloy-evm", @@ -11778,7 +11778,7 @@ dependencies = [ [[package]] name = "tempo-contracts" version = "1.8.1" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy-contract", "alloy-primitives", @@ -11789,7 +11789,7 @@ dependencies = [ [[package]] name = "tempo-dkg-onchain-artifacts" version = "1.8.2" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "bytes", "commonware-codec", @@ -11801,7 +11801,7 @@ dependencies = [ [[package]] name = "tempo-evm" version = "1.8.2" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy-consensus", "alloy-evm", @@ -11820,6 +11820,7 @@ dependencies = [ "reth-revm", "tempo-chainspec", "tempo-contracts", + "tempo-precompiles", "tempo-primitives", "tempo-revm", "thiserror 2.0.18", @@ -11829,7 +11830,7 @@ dependencies = [ [[package]] name = "tempo-precompiles" version = "1.8.2" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy", "alloy-evm", @@ -11850,7 +11851,7 @@ dependencies = [ [[package]] name = "tempo-precompiles-macros" version = "1.8.2" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy", "proc-macro2", @@ -11861,7 +11862,7 @@ dependencies = [ [[package]] name = "tempo-primitives" version = "1.8.1" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy-consensus", "alloy-eips", @@ -11892,7 +11893,7 @@ dependencies = [ [[package]] name = "tempo-revm" version = "1.8.2" -source = "git+https://github.com/tempoxyz/tempo?rev=563fc42578a9920067d129b0f191250e3a616a38#563fc42578a9920067d129b0f191250e3a616a38" +source = "git+https://github.com/tempoxyz/tempo?rev=2cf84002140367ff6de3268e5bf39972b8a0dc6c#2cf84002140367ff6de3268e5bf39972b8a0dc6c" dependencies = [ "alloy-consensus", "alloy-evm", @@ -11919,7 +11920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -13181,7 +13182,7 @@ dependencies = [ "watchexec-events", "watchexec-signals", "watchexec-supervisor", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -13319,7 +13320,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1a67d0b1dbe63..8a28be2d984ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -507,17 +507,17 @@ mpp = { git = "https://github.com/tempoxyz/mpp-rs", rev = "9de5344a961c13dd4b201 "reqwest-rustls-tls", "ws", ] } -tempo-chainspec = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38", default-features = false } -tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38", default-features = false, features = [ +tempo-chainspec = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c", default-features = false } +tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c", default-features = false, features = [ "serde", ] } -tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38", default-features = false } -tempo-evm = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38", default-features = false } -tempo-revm = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38", default-features = false, features = [ +tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c", default-features = false } +tempo-evm = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c", default-features = false } +tempo-revm = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c", default-features = false, features = [ "serde", ] } -tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38" } -tempo-precompiles = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38" } +tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c" } +tempo-precompiles = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c" } ## Pinned dependencies. Enabled for the workspace in crates/test-utils. @@ -594,9 +594,9 @@ alloy-op-hardforks = { git = "https://github.com/foundry-rs/optimism", branch = foundry-fork-db = { git = "https://github.com/foundry-rs/foundry-core", rev = "ba6246789fc12cf8fe78aa74aadf31d663f08c1f" } ## tempo — unify crates.io versions (pulled by mpp/foundry-wallets) with git rev -tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38" } -tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38" } -tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "563fc42578a9920067d129b0f191250e3a616a38" } +tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c" } +tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c" } +tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "2cf84002140367ff6de3268e5bf39972b8a0dc6c" } # solar solar = { package = "solar-compiler", git = "https://github.com/paradigmxyz/solar", rev = "da6b0c3a8c0edd23f8b1414c61418ba78d6c4670" } diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 0abaad2c03710..78e9b7638e5db 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -75,9 +75,9 @@ use alloy_rpc_types::{ trace::{ filter::TraceFilter, geth::{ - FourByteFrame, GethDebugBuiltInTracerType, GethDebugTracerType, - GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace, NoopFrame, - TraceResult, + CallConfig, FourByteFrame, GethDebugBuiltInTracerType, GethDebugTracerConfig, + GethDebugTracerType, GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace, + NoopFrame, TraceResult, }, parity::{LocalizedTransactionTrace, TraceResultsWithTransactionHash, TraceType}, }, @@ -184,7 +184,7 @@ use tempo_chainspec::hardfork::TempoHardfork; use tempo_evm::evm::TempoEvmFactory; use tempo_precompiles::{ TIP_FEE_MANAGER_ADDRESS, extend_tempo_precompiles, - storage::StorageCtx, + storage::{StorageActions, StorageCtx}, tip_fee_manager::{IFeeManager, TipFeeManager}, tip20::{ISSUER_ROLE, ITIP20, TIP20Token}, }; @@ -216,6 +216,20 @@ pub const MIN_TRANSACTION_GAS: u128 = 21000; // Gas per transaction creating a contract. pub const MIN_CREATE_GAS: u128 = 53000; +fn call_config_from_tracer_config( + tracer_config: GethDebugTracerConfig, +) -> Result { + let mut tracer_config = tracer_config.into_json(); + if let Some(config) = tracer_config.as_object_mut() + && !config.contains_key("onlyTopCall") + && let Some(only_top_level_call) = config.remove("onlyTopLevelCall") + { + config.insert("onlyTopCall".to_string(), only_top_level_call); + } + + GethDebugTracerConfig(tracer_config).into_call_config() +} + pub type State = foundry_evm::utils::StateChangeset; /// A block request, which includes the Pool Transactions if it's Pending @@ -1177,7 +1191,7 @@ impl Backend { cfg_env: &CfgEnv, ) { self.inject_precompiles(precompiles); - extend_tempo_precompiles(precompiles, cfg_env); + extend_tempo_precompiles(precompiles, cfg_env, StorageActions::disabled()); } /// Creates a concrete EVM, injects precompiles, transacts, and returns the result mapped @@ -2975,8 +2989,7 @@ where return match tracer { GethDebugTracerType::BuiltInTracer(tracer) => match tracer { GethDebugBuiltInTracerType::CallTracer => { - let call_config = tracer_config - .into_call_config() + let call_config = call_config_from_tracer_config(tracer_config) .map_err(|e| RpcError::invalid_params(e.to_string()))?; let mut inspector = self.build_inspector().with_tracing_config( @@ -3640,7 +3653,7 @@ where return Ok(res); } GethDebugBuiltInTracerType::CallTracer => { - return match tracer_config.into_call_config() { + return match call_config_from_tracer_config(tracer_config) { Ok(call_config) => { let inspector = TracingInspector::new( TracingInspectorConfig::from_geth_call_config(&call_config), diff --git a/crates/anvil/src/eth/backend/tempo.rs b/crates/anvil/src/eth/backend/tempo.rs index 80df57de93480..9a92882de6301 100644 --- a/crates/anvil/src/eth/backend/tempo.rs +++ b/crates/anvil/src/eth/backend/tempo.rs @@ -46,6 +46,7 @@ pub struct AnvilStorageProvider<'a> { gas_used: u64, gas_refunded: i64, reservoir: u64, + tip1060_storage_credits_enabled: bool, transient: HashMap<(Address, U256), U256>, hardfork: TempoHardfork, } @@ -66,6 +67,7 @@ impl<'a> AnvilStorageProvider<'a> { gas_used: 0, gas_refunded: 0, reservoir: 0, + tip1060_storage_credits_enabled: hardfork.is_t7(), transient: HashMap::new(), hardfork, } @@ -205,6 +207,10 @@ impl PrecompileStorageProvider for AnvilStorageProvider<'_> { fn amsterdam_eip8037_enabled(&self) -> bool { false } + + fn set_tip1060_storage_credits(&mut self, enabled: bool) { + self.tip1060_storage_credits_enabled = enabled && self.hardfork.is_t7(); + } } /// Initialize Tempo precompiles and fee tokens for Anvil. diff --git a/crates/anvil/tests/it/traces.rs b/crates/anvil/tests/it/traces.rs index 2509a523c7cd5..bb338326a28a3 100644 --- a/crates/anvil/tests/it/traces.rs +++ b/crates/anvil/tests/it/traces.rs @@ -239,6 +239,65 @@ async fn test_call_tracer_debug_trace_call() { } } + let receipt = internal_call_tx_builder.send().await.unwrap().get_receipt().await.unwrap(); + let internal_call_tx_hash = receipt.transaction_hash; + let trace_provider = handle.http_provider(); + + let internal_call_tx_traces: GethTrace = trace_provider + .raw_request( + "debug_traceTransaction".into(), + ( + internal_call_tx_hash, + serde_json::json!({ + "tracer": "callTracer", + "tracerConfig": { + "withLog": true + } + }), + ), + ) + .await + .unwrap(); + + match internal_call_tx_traces { + GethTrace::CallTracer(call_frame) => { + assert_eq!(call_frame.calls.len(), 1); + assert_eq!( + call_frame.calls.first().unwrap().to.unwrap(), + *simple_storage_contract.address() + ); + } + _ => { + unreachable!() + } + } + + let internal_call_only_top_level_call_tx_traces: GethTrace = trace_provider + .raw_request( + "debug_traceTransaction".into(), + ( + internal_call_tx_hash, + serde_json::json!({ + "tracer": "callTracer", + "tracerConfig": { + "onlyTopLevelCall": true, + "withLog": true + } + }), + ), + ) + .await + .unwrap(); + + match internal_call_only_top_level_call_tx_traces { + GethTrace::CallTracer(call_frame) => { + assert!(call_frame.calls.is_empty()); + } + _ => { + unreachable!() + } + } + // directly calling the SimpleStorage contract should not result in any internal calls let direct_call_tx = TransactionRequest::default() .from(wallets[1].address()) diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index 7eb97cf34e84e..9daef40bd01f9 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -963,6 +963,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { CastSubcommand::TxPool { command } => command.run().await?, CastSubcommand::Erc20Token { command } => command.run().await?, CastSubcommand::Tip20Token { command } => command.run().await?, + CastSubcommand::ReceivePolicy { command } => command.run().await?, CastSubcommand::Keychain { command } => command.run().await?, CastSubcommand::KeyAuthorization { command } => command.run().await?, CastSubcommand::Tempo { command } => command.run().await?, diff --git a/crates/cast/src/cmd/batch_mktx.rs b/crates/cast/src/cmd/batch_mktx.rs index 5a1325c2e7b11..235244b289dbc 100644 --- a/crates/cast/src/cmd/batch_mktx.rs +++ b/crates/cast/src/cmd/batch_mktx.rs @@ -21,7 +21,9 @@ use foundry_cli::{ utils::{self, LoadConfig, maybe_print_resolved_lane, resolve_lane}, }; use foundry_common::{ - FoundryTransactionBuilder, provider::ProviderBuilder, tempo::maybe_print_resolved_fee_token, + FoundryTransactionBuilder, + provider::ProviderBuilder, + tempo::{maybe_print_fee_token, resolve_and_set_fee_token}, }; use foundry_wallets::{TempoAccessKeyConfig, WalletOpts, WalletSigner}; use tempo_alloy::TempoNetwork; @@ -133,14 +135,16 @@ impl BatchMakeTxArgs { } let from = eth.wallet.from.unwrap_or(Address::ZERO); - let (tx, _) = tx_builder.build(from).await?; + let (mut tx, _) = tx_builder.build(from).await?; maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?; - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( (!config.eth_rpc_curl).then_some(&provider), Some(chain), - tx.fee_token(), + &mut tx, + Some(from), ) .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?; let raw_tx = alloy_primitives::hex::encode_prefixed(tx.build_unsigned()?.encoded_for_signing()); sh_println!("{raw_tx}")?; @@ -148,14 +152,16 @@ impl BatchMakeTxArgs { } if ethsign { - let (tx, _) = tx_builder.build(config.sender).await?; + let (mut tx, _) = tx_builder.build(config.sender).await?; maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?; - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( (!config.eth_rpc_curl).then_some(&provider), Some(chain), - tx.fee_token(), + &mut tx, + Some(config.sender), ) .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?; let signed_tx = provider.sign_transaction(tx).await?; sh_println!("{signed_tx}")?; return Ok(()); @@ -168,15 +174,17 @@ impl BatchMakeTxArgs { }; let signed_tx = if let Some(ref access_key) = tempo_access_key { - let (tx, _) = + let (mut tx, _) = tx_builder.build_with_access_key(access_key.wallet_address, access_key).await?; maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?; - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( (!config.eth_rpc_curl).then_some(&provider), Some(chain), - tx.fee_token(), + &mut tx, + Some(access_key.wallet_address), ) .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?; let raw_tx = tx .sign_with_access_key( &provider, @@ -189,14 +197,16 @@ impl BatchMakeTxArgs { alloy_primitives::hex::encode(raw_tx) } else { tx::validate_from_address(eth.wallet.from, Signer::address(&signer))?; - let (tx, _) = tx_builder.build(&signer).await?; + let (mut tx, _) = tx_builder.build(&signer).await?; maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?; - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( (!config.eth_rpc_curl).then_some(&provider), Some(chain), - tx.fee_token(), + &mut tx, + Some(Signer::address(&signer)), ) .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?; let envelope = tx.build(&EthereumWallet::new(signer)).await?; alloy_primitives::hex::encode(envelope.encoded_2718()) }; diff --git a/crates/cast/src/cmd/batch_send.rs b/crates/cast/src/cmd/batch_send.rs index 2001841922c25..af9d359a52721 100644 --- a/crates/cast/src/cmd/batch_send.rs +++ b/crates/cast/src/cmd/batch_send.rs @@ -147,6 +147,7 @@ impl BatchSendArgs { provider, tx, Some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, @@ -174,6 +175,7 @@ impl BatchSendArgs { &signer, access_key, Some(chain), + None, send_tx.cast_async, send_tx.confirmations, timeout, @@ -196,6 +198,7 @@ impl BatchSendArgs { provider, tx_request, Some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index 2ffbb428ccc26..6ece5699b8bc2 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -24,7 +24,7 @@ use foundry_common::{ fmt::{UIfmt, UIfmtReceiptExt}, provider::{ProviderBuilder, RetryProviderWithSigner}, shell, - tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_resolved_fee_token}, + tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_fee_token, resolve_and_set_fee_token}, }; #[doc(hidden)] pub use foundry_config::{Chain, utils::*}; @@ -389,6 +389,7 @@ impl Erc20Subcommand { (pre_resolved_signer, tempo_keychain) }; let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash; + let sponsor_fee_payer = tx_opts.tempo.sponsor; let expires_at = tx_opts.tempo.resolve_expires(); let tempo_sponsor = if print_sponsor_hash { None } else { tx_opts.tempo.sponsor_config().await? }; @@ -412,6 +413,15 @@ impl Erc20Subcommand { .await?; if needs_sponsor_payload { if print_sponsor_hash { + if let Some(fee_payer) = sponsor_fee_payer { + resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + Some(chain), + &mut tx, + Some(fee_payer), + ) + .await?; + } let hash = tx .compute_sponsor_hash(access_key.wallet_address) .ok_or_else(|| { @@ -423,6 +433,13 @@ impl Erc20Subcommand { return Ok(()); } if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + Some(chain), + &mut tx, + ) + .await?; sponsor .attach_and_print::( &mut tx, @@ -436,11 +453,12 @@ impl Erc20Subcommand { tx, signer, access_key, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, $send_tx.cast_async, $send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; } else if let Some(browser) = $send_tx.browser.run::().await? { @@ -454,6 +472,15 @@ impl Erc20Subcommand { tx_opts.apply::(&mut tx, chain.is_legacy()); fill_tx(&$provider, &mut tx, browser.address(), chain, true).await?; if print_sponsor_hash { + if let Some(fee_payer) = sponsor_fee_payer { + resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + Some(chain), + &mut tx, + Some(fee_payer), + ) + .await?; + } let hash = tx.compute_sponsor_hash(browser.address()).ok_or_else(|| { eyre::eyre!("This network does not support sponsored transactions") })?; @@ -461,14 +488,28 @@ impl Erc20Subcommand { return Ok(()); } if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + Some(chain), + &mut tx, + ) + .await?; sponsor.attach_and_print::(&mut tx, browser.address()).await?; + } else { + let fee_token = resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + Some(chain), + &mut tx, + Some(browser.address()), + ) + .await?; + maybe_print_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + fee_token, + ) + .await?; } - maybe_print_resolved_fee_token( - (!config.eth_rpc_curl).then_some(&$provider), - Some(chain), - tx.fee_token(), - ) - .await?; let tx_hash = browser.send_transaction_via_browser(tx).await?; CastTxSender::new(&$provider) .print_tx_result( @@ -489,6 +530,15 @@ impl Erc20Subcommand { if needs_sponsor_payload { fill_tx(&$provider, &mut tx, from, chain, false).await?; if print_sponsor_hash { + if let Some(fee_payer) = sponsor_fee_payer { + resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + Some(chain), + &mut tx, + Some(fee_payer), + ) + .await?; + } let hash = tx.compute_sponsor_hash(from).ok_or_else(|| { eyre::eyre!("This network does not support sponsored transactions") })?; @@ -496,18 +546,26 @@ impl Erc20Subcommand { return Ok(()); } if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&$provider), + Some(chain), + &mut tx, + ) + .await?; sponsor.attach_and_print::(&mut tx, from).await?; } } cast_send( $provider, tx, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, $send_tx.cast_async, $send_tx.sync, $send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; } diff --git a/crates/cast/src/cmd/keychain.rs b/crates/cast/src/cmd/keychain.rs index 46588073c0bb1..5da4f4209fd0e 100644 --- a/crates/cast/src/cmd/keychain.rs +++ b/crates/cast/src/cmd/keychain.rs @@ -4,7 +4,7 @@ use foundry_wallets::BrowserWalletOpts; use std::time::Duration; use alloy_network::{EthereumWallet, TransactionBuilder}; -use alloy_primitives::{Address, B256, U256, hex}; +use alloy_primitives::{Address, B256, Bytes, U256, hex}; use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; use alloy_rlp::Encodable; use alloy_rpc_types::BlockId; @@ -24,8 +24,8 @@ use foundry_common::{ provider::ProviderBuilder, sh_warn, shell, tempo::{ - self, KeyType, KeysFile, TEMPO_BROWSER_GAS_BUFFER, WalletType, - maybe_print_resolved_fee_token, read_tempo_keys_file, tempo_keys_path, + self, KeyType, KeysFile, TEMPO_BROWSER_GAS_BUFFER, WalletType, maybe_print_fee_token, + read_tempo_keys_file, resolve_and_set_fee_token, tempo_keys_path, }, }; use foundry_evm::hardfork::TempoHardfork; @@ -38,8 +38,11 @@ use tempo_contracts::precompiles::{ self, CallScope, KeyInfo, KeyRestrictions, LegacyTokenLimit, SelectorRule, SignatureType, TokenLimit, }, - ITIP20, PATH_USD_ADDRESS, - account_keychain::{authorizeKeyCall, authorizeKeyWithWitnessCall, legacyAuthorizeKeyCall}, + ISignatureVerifier, ITIP20, PATH_USD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, + account_keychain::{ + authorizeAdminKeyCall, authorizeKeyCall, authorizeKeyWithWitnessCall, + legacyAuthorizeKeyCall, + }, }; use tempo_primitives::transaction::{ CallScope as AuthCallScope, KeyAuthorization, PrimitiveSignature, @@ -179,9 +182,19 @@ pub enum KeychainSubcommand { /// Optional TIP-1053 witness to bind to this on-chain authorization. /// /// `0x000...000` is a valid present witness and is distinct from omitting the flag. + /// + /// For `--admin`, the `authorizeAdminKey` precompile always takes a witness; omitting + /// this flag submits `bytes32(0)` (which fails if that witness is already burned). #[arg(long)] witness: Option, + /// Authorize a T6 admin access key via `authorizeAdminKey` (key-management only). + /// + /// Admin keys may authorize/revoke other keys but cannot carry an expiry, spending limits, + /// or call scopes. The account is the signing (precompile caller) account. + #[arg(long)] + admin: bool, + #[command(flatten)] tx: TransactionOpts, @@ -228,6 +241,58 @@ pub enum KeychainSubcommand { rpc: RpcOpts, }, + /// Check whether a key is the root key or an active admin key for an account (T6). + #[command(name = "is-admin")] + IsAdmin { + /// The account (root) address. + account: Address, + + /// The key address to check. + key_address: Address, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Verify a Tempo keychain signature against an account's active access key (T6). + /// + /// `signature` must be an encoded Tempo keychain signature (not a raw 65-byte secp256k1 + /// signature). Returns true only for an active access key, never the root key. The supplied + /// `hash` should already be domain-separated by the caller. + Verify { + /// The expected (root) account that embeds the key. + account: Address, + + /// The 32-byte message hash that was signed. + hash: B256, + + /// The encoded Tempo keychain signature. + signature: Bytes, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Verify a Tempo keychain signature against an account's root or admin key (T6). + /// + /// `signature` must be an encoded Tempo keychain signature (not a raw 65-byte secp256k1 + /// signature). Returns true for the root key or an active admin key. `hash` should already be + /// domain-separated by the caller. + #[command(name = "verify-admin")] + VerifyAdmin { + /// The expected (root) account that embeds the key. + account: Address, + + /// The 32-byte message hash that was signed. + hash: B256, + + /// The encoded Tempo keychain signature. + signature: Bytes, + + #[command(flatten)] + rpc: RpcOpts, + }, + /// Query the remaining spending limit for a key on a specific token. #[command(name = "rl", visible_alias = "remaining-limit")] RemainingLimit { @@ -310,19 +375,49 @@ pub enum KeyAuthorizationSubcommand { Encode { #[command(flatten)] authorization: KeyAuthorizationArgs, + + /// Bind this authorization to a target account (T6). + /// + /// Required for `--admin` so the authorization cannot be replayed across accounts sharing + /// the same admin key. May also bind a plain authorization without `--admin`. + #[arg(long, value_name = "ADDRESS")] + account: Option
, }, /// Sign and RLP-encode a Tempo key authorization. + /// + /// With an admin access-key signer the bound account is the key's root and is derived + /// automatically; with a direct signer, `--bind-account` binds to another root. A root-signed + /// `--admin` authorization defaults the account to the signer. Sign { #[command(flatten)] authorization: KeyAuthorizationArgs, + /// Bind this authorization to a target (root) account (T6). + /// + /// Named `--bind-account` to avoid clashing with the wallet keystore `--account` selector. + #[arg(long = "bind-account", value_name = "ADDRESS")] + account: Option
, + #[command(flatten)] wallet: Box, #[command(flatten)] browser: BrowserWalletOpts, }, + + /// Decode and inspect a Tempo key authorization (signed or unsigned). + /// + /// Accepts the hex RLP from `encode` (unsigned) or `sign` (signed) and prints its fields, + /// including the T6 `is_admin`/`account` fields and the recovered signer for signed input. + Inspect { + /// Hex-encoded RLP key authorization (signed or unsigned). + authorization: String, + + /// Expected bound account; rejects a mismatched or replayed account-bound authorization. + #[arg(long, value_name = "ADDRESS")] + account: Option
, + }, } /// Common fields for `cast key-authorization encode` and `cast key-authorization sign`. @@ -366,6 +461,14 @@ pub struct KeyAuthorizationArgs { /// `0x000...000` is a valid present witness and is distinct from omitting the flag. #[arg(long)] witness: Option, + + /// Authorize a T6 admin access key (key-management only). + /// + /// Admin keys may authorize/revoke other keys but cannot carry an expiry, spending limits, or + /// call scopes, and require a bound account (`--account` for `encode`, signer-derived for + /// `sign`). + #[arg(long)] + admin: bool, } /// Higher-level access-key policy editing commands. @@ -669,9 +772,11 @@ impl KeychainSubcommand { scope, scopes_json, witness, + admin, tx, send_tx, } => { + let scopes_present = scopes_json.is_some() || !scope.is_empty(); let all_scopes = if let Some(ScopesJson(json_scopes)) = scopes_json { json_scopes } else { @@ -684,7 +789,9 @@ impl KeychainSubcommand { enforce_limits, limits, all_scopes, + scopes_present, witness, + admin, tx, send_tx, ) @@ -697,6 +804,15 @@ impl KeychainSubcommand { Self::IsWitnessBurned { account, witness, rpc } => { run_is_witness_burned(account, witness, rpc).await } + Self::IsAdmin { account, key_address, rpc } => { + run_is_admin(account, key_address, rpc).await + } + Self::Verify { account, hash, signature, rpc } => { + run_verify_keychain(account, hash, signature, rpc, false).await + } + Self::VerifyAdmin { account, hash, signature, rpc } => { + run_verify_keychain(account, hash, signature, rpc, true).await + } Self::RemainingLimit { wallet_address, key_address, token, rpc } => { run_remaining_limit(wallet_address, key_address, token, rpc).await } @@ -717,9 +833,12 @@ impl KeychainSubcommand { impl KeyAuthorizationSubcommand { pub async fn run(self) -> Result<()> { match self { - Self::Encode { authorization } => run_key_auth_encode(authorization), - Self::Sign { authorization, wallet, browser } => { - run_key_auth_sign(authorization, *wallet, browser).await + Self::Encode { authorization, account } => run_key_auth_encode(authorization, account), + Self::Sign { authorization, account, wallet, browser } => { + run_key_auth_sign(authorization, account, *wallet, browser).await + } + Self::Inspect { authorization, account } => { + run_key_auth_inspect(&authorization, account) } } } @@ -852,6 +971,21 @@ async fn run_inspect( let info: KeyInfo = provider.get_keychain_key(metadata.root_account, key_address).await?; let provisioned = info.keyId != Address::ZERO; let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?; + let is_t6 = is_tempo_hardfork_active(&provider, TempoHardfork::T6).await?; + + // On T6, `isAdminKey` is authoritative for the root/admin distinction. + let is_admin = if is_t6 { + provider.account_keychain().isAdminKey(metadata.root_account, key_address).call().await? + } else { + false + }; + let role = if key_address == metadata.root_account { + "root" + } else if is_admin { + "admin" + } else { + "limited" + }; let mut limits = Vec::new(); if info.enforceLimits { @@ -912,6 +1046,8 @@ async fn run_inspect( "key_id": key_address.to_string(), "provisioned": provisioned, "type": key_type, + "role": role, + "is_admin": is_admin, "expiry": provisioned.then_some(info.expiry), "expiry_human": provisioned.then(|| format_expiry_for_inspect(info.expiry)), "enforce_limits": info.enforceLimits, @@ -932,6 +1068,7 @@ async fn run_inspect( sh_println!("Root account: {}", metadata.root_account)?; sh_println!("Key id: {key_address}")?; sh_println!("Type: {key_type}")?; + sh_println!("Role: {role}")?; if info.isRevoked { sh_println!("Status: revoked")?; @@ -2651,7 +2788,9 @@ async fn run_authorize( enforce_limits: bool, limits: Vec, allowed_calls: Vec, + scopes_present: bool, witness: Option, + admin: bool, tx_opts: TransactionOpts, send_tx: SendTxOpts, ) -> Result<()> { @@ -2660,6 +2799,33 @@ async fn run_authorize( let config = send_tx.eth.load_config()?; let provider = ProviderBuilder::::from_config(&config)?.build()?; + // T6 admin keys are key-management only and use a dedicated precompile entrypoint. + if admin { + if !is_tempo_hardfork_active(&provider, TempoHardfork::T6).await? { + eyre::bail!("--admin requires a Tempo T6-capable AccountKeychain RPC"); + } + // u64::MAX is the no-expiry default; anything else is an explicit expiry admin keys reject. + eyre::ensure!(expiry == u64::MAX, "--admin cannot be combined with an explicit --expiry"); + eyre::ensure!( + !enforce, + "--admin cannot be combined with spending limits (--enforce-limits / --limit)" + ); + eyre::ensure!( + !scopes_present, + "--admin cannot be combined with call scopes (--scope / --scopes)" + ); + + // `authorizeAdminKey` requires a witness argument; omitting `--witness` submits bytes32(0). + let calldata = authorizeAdminKeyCall { + keyId: key_address, + signatureType: key_type, + witness: witness.unwrap_or(B256::ZERO), + } + .abi_encode(); + send_keychain_tx(calldata, tx_opts, &send_tx, None).await?; + return Ok(()); + } + let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?; if witness.is_some() && !is_tempo_hardfork_active(&provider, TempoHardfork::T5).await? { eyre::bail!("--witness requires a Tempo T5-capable AccountKeychain RPC"); @@ -2716,8 +2882,8 @@ async fn run_authorize( Ok(()) } -fn run_key_auth_encode(args: KeyAuthorizationArgs) -> Result<()> { - let authorization = args.into_authorization()?; +fn run_key_auth_encode(args: KeyAuthorizationArgs, account: Option
) -> Result<()> { + let authorization = args.into_authorization(account)?; let encoded = encode_key_authorization(&authorization); if shell::is_json() { @@ -2725,6 +2891,9 @@ fn run_key_auth_encode(args: KeyAuthorizationArgs) -> Result<()> { "key_authorization": hex::encode_prefixed(&encoded), "signature_hash": authorization.signature_hash().to_string(), "rlp_length": encoded.len(), + "is_admin": authorization.is_admin(), + "account": authorization.account.map(|account| account.to_string()), + "witness": authorization.witness().map(|witness| witness.to_string()), }); sh_println!("{}", serde_json::to_string_pretty(&json)?)?; } else { @@ -2736,22 +2905,27 @@ fn run_key_auth_encode(args: KeyAuthorizationArgs) -> Result<()> { async fn run_key_auth_sign( args: KeyAuthorizationArgs, + account: Option
, wallet: WalletOpts, browser: BrowserWalletOpts, ) -> Result<()> { - let authorization = args.into_authorization()?; + let is_admin = args.admin; - // TODO: remove this check once browser supports T5 KeyAuthorization fields - if browser.browser && authorization.witness().is_some() { - eyre::bail!("browser key authorization signing does not support T5 fields yet: witness"); + // TODO: remove this check once browser supports T5/T6 KeyAuthorization fields. Guard before + // `browser.run()` so the browser flow never starts for unsupported authorizations. + if browser.browser && (args.witness.is_some() || is_admin || account.is_some()) { + eyre::bail!( + "browser key authorization signing does not support T5/T6 fields yet: witness, admin, account" + ); } - let authorized_key_type = auth_signature_type_name(&authorization.key_type); - let signature_hash = authorization.signature_hash(); - if let Some(browser) = browser.run::().await? { let signer_address = browser.address(); ensure_key_authorization_root_sender(signer_address, wallet.from)?; + // The browser path rejects admin/witness/account above, so there is nothing to bind. + let authorization = args.into_authorization(None)?; + let authorized_key_type = auth_signature_type_name(&authorization.key_type); + let signature_hash = authorization.signature_hash(); let signed = browser.sign_key_authorization(authorization).await?; return print_signed_key_authorization( &signed, @@ -2762,19 +2936,37 @@ async fn run_key_auth_sign( } let (signer, tempo_access_key) = wallet.maybe_signer().await?; - if tempo_access_key.is_some() { - eyre::bail!( - "Tempo access keys cannot sign key authorizations; use a persistent root signer" - ); - } let signer = signer.ok_or_else(|| { eyre::eyre!( - "a persistent root signer is required to sign key authorizations; pass a signer with \ + "a signer is required to sign key authorizations; pass a signer with \ --browser, --private-key, --keystore, Ledger, Trezor, AWS, GCP, or Turnkey" ) })?; let signer_address = signer.address(); - ensure_key_authorization_root_sender(signer_address, wallet.from)?; + + // Resolve the account this authorization is bound to (T6 replay protection). + let bound_account = if let Some(access_key) = tempo_access_key.as_ref() { + // The access key (an admin key) signs for its root, so bind to the root, not the signer. + if let Some(explicit) = account { + eyre::ensure!( + explicit == access_key.wallet_address, + "--bind-account {explicit} does not match the selected Tempo access key's root account {}", + access_key.wallet_address, + ); + } + Some(access_key.wallet_address) + } else { + ensure_key_authorization_root_sender(signer_address, wallet.from)?; + match account { + Some(explicit) => Some(explicit), + None if is_admin => Some(signer_address), + None => None, + } + }; + + let authorization = args.into_authorization(bound_account)?; + let authorized_key_type = auth_signature_type_name(&authorization.key_type); + let signature_hash = authorization.signature_hash(); let signature = signer.sign_hash(&signature_hash).await?; let signed = authorization.into_signed(PrimitiveSignature::Secp256k1(signature)); print_signed_key_authorization(&signed, signature_hash, signer_address, authorized_key_type) @@ -2798,6 +2990,8 @@ fn print_signed_key_authorization( "authorized_key_type": authorized_key_type, "signature_type": signature_type, "witness": signed.authorization.witness().map(|witness| witness.to_string()), + "is_admin": signed.authorization.is_admin(), + "account": signed.authorization.account.map(|account| account.to_string()), }); sh_println!("{}", serde_json::to_string_pretty(&json)?)?; } else { @@ -2813,8 +3007,125 @@ fn encode_key_authorization(authorization: &T) -> Vec { out } +/// Decode a hex RLP key authorization (signed or unsigned) and validate its account binding. +/// +/// Tries the signed shape first, then the unsigned one. Returns the authorization, whether the +/// input was signed, and the best-effort recovered signer. When `expected_account` is set the +/// decoded authorization must be bound to exactly that account. +fn decode_and_validate_key_authorization( + authorization: &str, + expected_account: Option
, +) -> Result<(KeyAuthorization, bool, Option
)> { + let raw = authorization.trim(); + + let (auth, signed, signer) = + match tempo::decode_key_authorization::(raw) { + // Signer recovery is best-effort so `inspect` still surfaces fields for a corrupt sig. + Ok(signed) => { + let signer = signed.recover_signer().ok(); + (signed.authorization, true, signer) + } + Err(signed_err) => match tempo::decode_key_authorization::(raw) { + Ok(unsigned) => (unsigned, false, None), + Err(unsigned_err) => eyre::bail!( + "could not decode key authorization as signed ({signed_err}) or unsigned \ + ({unsigned_err})" + ), + }, + }; + + // Mirror the chain's T6 admin invariants so `inspect` rejects a malformed admin authorization. + if let Some(account) = auth.account { + eyre::ensure!( + account != Address::ZERO, + "key authorization account cannot be the zero address" + ); + } + if auth.is_admin() { + // A root-signed admin authorization may omit `account` (it is only required when the signer + // is not the target root), so `inspect` does not require it here. Binding is still enforced + // below when `--account` is supplied. + eyre::ensure!(auth.expiry.is_none(), "admin key authorization cannot carry an expiry"); + eyre::ensure!( + auth.limits.is_none(), + "admin key authorization cannot carry spending limits" + ); + eyre::ensure!( + auth.allowed_calls.is_none(), + "admin key authorization cannot carry call scopes" + ); + } + + // `--account` rejects a replayed or mismatched account-bound authorization. + if let Some(expected) = expected_account { + match auth.account { + Some(account) if account == expected => {} + Some(account) => eyre::bail!( + "key authorization is bound to account {account} but {expected} was expected" + ), + None => eyre::bail!( + "expected key authorization bound to account {expected} but it has no account field" + ), + } + } + + Ok((auth, signed, signer)) +} + +/// `cast key-authorization inspect` — decode a signed or unsigned key authorization and print its +/// fields, including the T6 `is_admin` / `account` fields. +fn run_key_auth_inspect(authorization: &str, expected_account: Option
) -> Result<()> { + let (auth, signed, signer) = + decode_and_validate_key_authorization(authorization, expected_account)?; + + if shell::is_json() { + let json = serde_json::json!({ + "signed": signed, + "signer": signer.map(|signer| signer.to_string()), + "chain_id": auth.chain_id, + "key_address": auth.key_id.to_string(), + "key_type": auth_signature_type_name(&auth.key_type), + "is_admin": auth.is_admin(), + "account": auth.account.map(|account| account.to_string()), + "expiry": auth.expiry.map(|expiry| expiry.get()), + "witness": auth.witness().map(|witness| witness.to_string()), + "enforce_limits": auth.limits.is_some(), + "scoped_calls": auth.allowed_calls.is_some(), + }); + sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + } else { + sh_println!("Signed: {signed}")?; + if let Some(signer) = signer { + sh_println!("Signer: {signer}")?; + } + sh_println!("Chain ID: {}", auth.chain_id)?; + sh_println!("Key Address: {}", auth.key_id)?; + sh_println!("Key Type: {}", auth_signature_type_name(&auth.key_type))?; + sh_println!("Admin: {}", auth.is_admin())?; + if let Some(account) = auth.account { + sh_println!("Account: {account}")?; + } + match auth.expiry { + Some(expiry) => sh_println!("Expiry: {}", expiry.get())?, + None => sh_println!("Expiry: none")?, + } + match auth.witness() { + Some(witness) => sh_println!("Witness: {witness}")?, + None => sh_println!("Witness: none")?, + } + sh_println!("Enforce Lim: {}", auth.limits.is_some())?; + sh_println!("Scoped Calls: {}", auth.allowed_calls.is_some())?; + } + + Ok(()) +} + impl KeyAuthorizationArgs { - fn into_authorization(self) -> Result { + /// Build a [`KeyAuthorization`] from these args, binding it to `account` when present. + /// + /// For `--admin`, `account` must be `Some` (passed via `--account` for `encode`, or derived + /// from the root signer for `sign`). + fn into_authorization(self, account: Option
) -> Result { let (scopes, explicit_scopes_json) = if let Some(AuthScopesJson(json_scopes)) = self.scopes_json { (json_scopes, true) @@ -2822,6 +3133,15 @@ impl KeyAuthorizationArgs { (self.scope, false) }; + let scopes_present = explicit_scopes_json || !scopes.is_empty(); + validate_admin_key_authorization( + self.admin, + account, + self.expiry.is_some(), + self.enforce_limits || !self.limits.is_empty(), + scopes_present, + )?; + let mut authorization = KeyAuthorization::unrestricted(self.chain_id, self.key_type, self.key_address); @@ -2836,7 +3156,7 @@ impl KeyAuthorizationArgs { authorization = authorization.with_limits(self.limits); } - if explicit_scopes_json || !scopes.is_empty() { + if scopes_present { authorization = authorization.with_allowed_calls(scopes); } @@ -2844,10 +3164,50 @@ impl KeyAuthorizationArgs { authorization = authorization.with_witness(witness); } + // Apply T6 admin / account binding last, after the restriction fields are validated above. + if self.admin { + // `validate_admin_key_authorization` guarantees `account` is `Some` here. + authorization = authorization.into_admin(account.expect("admin requires account")); + } else if let Some(account) = account { + authorization = authorization.with_account(account); + } + Ok(authorization) } } +/// Enforce the T6 admin access-key invariants when constructing a key authorization. +/// +/// Admin keys are key-management only: no expiry, spending limits, or call scopes, and they must be +/// bound to a target account (to prevent cross-account replay). A TIP-1053 witness is still +/// allowed. +fn validate_admin_key_authorization( + admin: bool, + account: Option
, + has_expiry: bool, + has_limits: bool, + has_scopes: bool, +) -> Result<()> { + if let Some(account) = account { + eyre::ensure!(account != Address::ZERO, "--account cannot be the zero address"); + } + + if admin { + eyre::ensure!(account.is_some(), "--admin requires --account"); + eyre::ensure!(!has_expiry, "--admin cannot be combined with --expiry"); + eyre::ensure!( + !has_limits, + "--admin cannot be combined with spending limits (--enforce-limits / --limit)" + ); + eyre::ensure!( + !has_scopes, + "--admin cannot be combined with call scopes (--scope / --scopes)" + ); + } + + Ok(()) +} + /// `cast keychain revoke` / `cast keychain rev` — revoke a key on-chain. async fn run_revoke( key_address: Address, @@ -2904,6 +3264,68 @@ async fn run_is_witness_burned(account: Address, witness: B256, rpc: RpcOpts) -> Ok(()) } +/// `cast keychain is-admin` — check whether a key is the root or an active admin key (T6). +async fn run_is_admin(account: Address, key_address: Address, rpc: RpcOpts) -> Result<()> { + let config = rpc.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + if !is_tempo_hardfork_active(&provider, TempoHardfork::T6).await? { + eyre::bail!("is-admin requires a Tempo T6-capable AccountKeychain RPC"); + } + + let is_admin = provider.account_keychain().isAdminKey(account, key_address).call().await?; + + if shell::is_json() { + let json = serde_json::json!({ + "account": account.to_string(), + "key_address": key_address.to_string(), + "is_admin": is_admin, + }); + sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + } else { + sh_println!("{is_admin}")?; + } + + Ok(()) +} + +/// `cast keychain verify` / `verify-admin` — verify a Tempo keychain signature (T6). +async fn run_verify_keychain( + account: Address, + hash: B256, + signature: Bytes, + rpc: RpcOpts, + admin: bool, +) -> Result<()> { + let config = rpc.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let command = if admin { "verify-admin" } else { "verify" }; + if !is_tempo_hardfork_active(&provider, TempoHardfork::T6).await? { + eyre::bail!("{command} requires a Tempo T6-capable SignatureVerifier RPC"); + } + + let verifier = ISignatureVerifier::new(SIGNATURE_VERIFIER_ADDRESS, &provider); + let valid = if admin { + verifier.verifyKeychainAdmin(account, hash, signature.clone()).call().await? + } else { + verifier.verifyKeychain(account, hash, signature.clone()).call().await? + }; + + if shell::is_json() { + let json = serde_json::json!({ + "account": account.to_string(), + "hash": hash.to_string(), + "signature": signature.to_string(), + "admin": admin, + "valid": valid, + }); + sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + } else { + sh_println!("{valid}")?; + } + + Ok(()) +} + /// `cast keychain rl` — query remaining spending limit. async fn run_remaining_limit( wallet_address: Address, @@ -3093,11 +3515,18 @@ pub(crate) async fn resolve_keychain_root_signer( return Ok(KeychainRootSigner::Browser(browser)); } + // The T6 spec allows an active admin access key to authorize/revoke other keys, but submitting + // these AccountKeychain mutators as access-key-signed precompile calldata reverts on-chain with + // `UnauthorizedCaller()` on the pinned Tempo build (gas estimation succeeds because it injects + // an override key id, while real execution recovers the signer). Reject before broadcasting + // rather than emitting a guaranteed-revert transaction; use a root signer for direct mutations. if tempo_access_key.is_some() { eyre::bail!( - "keychain policy changes must be signed by the root account; the selected `--from` \ - resolved to a Tempo access key. Use `--browser` for passkey roots, or pass a root \ - account signer with `--private-key`, `--keystore`, Ledger, Trezor, AWS, GCP, or Turnkey." + "submitting AccountKeychain admin mutators (authorize / revoke / policy) signed by a \ + Tempo access key currently reverts on-chain with UnauthorizedCaller() on the pinned \ + Tempo build, even for an active admin key. Use a root account signer (--browser for \ + passkey roots, or --private-key / --keystore / Ledger / Trezor / AWS / GCP / Turnkey) \ + for direct mutations." ); } @@ -3137,6 +3566,7 @@ pub(crate) async fn send_keychain_tx_with_root_signer( before_submit: impl FnOnce() -> Result<()>, ) -> Result { let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash; + let sponsor_fee_payer = tx_opts.tempo.sponsor; let expires_at = tx_opts.tempo.resolve_expires(); let tempo_sponsor = if print_sponsor_hash { None } else { tx_opts.tempo.sponsor_config().await? }; @@ -3162,7 +3592,17 @@ pub(crate) async fn send_keychain_tx_with_root_signer( if print_sponsor_hash { let from = root_signer.address(); - let (tx, _) = builder.build(from).await?; + let chain = builder.chain(); + let (mut tx, _) = builder.build(from).await?; + if let Some(fee_payer) = sponsor_fee_payer { + resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + Some(fee_payer), + ) + .await?; + } let hash = tx .compute_sponsor_hash(from) .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))?; @@ -3186,14 +3626,25 @@ pub(crate) async fn send_keychain_tx_with_root_signer( tx.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER); } if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + ) + .await?; sponsor.attach_and_print::(&mut tx, browser.address()).await?; + } else { + let fee_token = resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + Some(browser.address()), + ) + .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token) + .await?; } - maybe_print_resolved_fee_token( - (!config.eth_rpc_curl).then_some(&provider), - Some(chain), - tx.fee_token(), - ) - .await?; before_submit()?; let tx_hash = browser.send_transaction_via_browser(tx).await?; @@ -3207,7 +3658,24 @@ pub(crate) async fn send_keychain_tx_with_root_signer( let (mut tx, _) = builder.build(from).await?; maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?; if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + ) + .await?; sponsor.attach_and_print::(&mut tx, from).await?; + } else { + let fee_token = resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + Some(from), + ) + .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token) + .await?; } before_submit()?; @@ -3219,12 +3687,13 @@ pub(crate) async fn send_keychain_tx_with_root_signer( cast_send( provider, tx, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; } @@ -3665,15 +4134,23 @@ fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> { sh_println!("Expiry: {}", format_expiry(expiry))?; } + let decoded = decoded_entry_key_authorization(entry); + let is_admin = decoded.as_ref().is_some_and(|signed| signed.authorization.is_admin()); + sh_println!("Role: {}", local_key_role(entry, is_admin))?; + sh_println!("Has Key: {}", entry.has_inline_key())?; sh_println!("Has Auth: {}", entry.key_authorization.is_some())?; - if let Some(signed) = decoded_entry_key_authorization(entry) { + if let Some(signed) = &decoded { let witness = signed .authorization .witness() .map(|witness| witness.to_string()) .unwrap_or_else(|| "(none)".to_string()); sh_println!("Auth Witness: {witness}")?; + sh_println!("Auth Admin: {}", signed.authorization.is_admin())?; + if let Some(account) = signed.authorization.account { + sh_println!("Auth Account: {account}")?; + } } if !entry.limits.is_empty() { @@ -3688,9 +4165,16 @@ fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> { fn key_entry_to_json(entry: &tempo::KeyEntry) -> serde_json::Value { let is_direct = entry.key_address.is_none_or(|key_address| key_address == entry.wallet_address); - let authorization_witness = decoded_entry_key_authorization(entry) - .and_then(|signed| signed.authorization.witness()) - .map(|witness| witness.to_string()); + let decoded = decoded_entry_key_authorization(entry); + let authorization_witness = + decoded.as_ref().and_then(|signed| signed.authorization.witness()).map(|w| w.to_string()); + let authorization_is_admin = + decoded.as_ref().is_some_and(|signed| signed.authorization.is_admin()); + let authorization_account = decoded + .as_ref() + .and_then(|signed| signed.authorization.account) + .map(|account| account.to_string()); + let role = local_key_role(entry, authorization_is_admin); let limits: Vec<_> = entry .limits @@ -3714,11 +4198,30 @@ fn key_entry_to_json(entry: &tempo::KeyEntry) -> serde_json::Value { "expiry_human": entry.expiry.map(format_expiry), "has_key": entry.has_inline_key(), "has_authorization": entry.key_authorization.is_some(), + "role": role, "authorization_witness": authorization_witness, + "authorization_is_admin": authorization_is_admin, + "authorization_account": authorization_account, "limits": limits, }) } +/// Classify a local key entry's role for display. +/// +/// `root` when the key is the account EOA itself, `admin` when a decoded local authorization marks +/// the key as a T6 admin key, otherwise `access`. This reflects only locally available data; use +/// `keychain is-admin` for the authoritative on-chain role. +fn local_key_role(entry: &tempo::KeyEntry, is_admin: bool) -> &'static str { + let is_direct = entry.key_address.is_none_or(|key_address| key_address == entry.wallet_address); + if is_direct { + "root" + } else if is_admin { + "admin" + } else { + "limited" + } +} + fn decoded_entry_key_authorization(entry: &tempo::KeyEntry) -> Option { let raw = entry.key_authorization.as_deref()?.trim(); if raw.is_empty() { @@ -3885,6 +4388,7 @@ mod tests { witness, .. }, + .. } => { assert_eq!(chain_id, 4217); assert_eq!(key_address, Address::from_str(key).unwrap()); @@ -3943,6 +4447,7 @@ mod tests { scope: vec![], scopes_json: None, witness: None, + admin: false, } } @@ -3968,10 +4473,10 @@ mod tests { #[test] fn test_key_auth_encode_preserves_zero_witness_presence() { - let absent = key_auth_args().into_authorization().unwrap(); + let absent = key_auth_args().into_authorization(None).unwrap(); let mut args = key_auth_args(); args.witness = Some(B256::ZERO); - let zero_witness = args.into_authorization().unwrap(); + let zero_witness = args.into_authorization(None).unwrap(); assert_eq!(absent.witness(), None); assert_eq!(zero_witness.witness(), Some(B256::ZERO)); @@ -3979,12 +4484,225 @@ mod tests { assert_ne!(encode_key_authorization(&absent), encode_key_authorization(&zero_witness)); } + #[test] + fn test_admin_key_auth_roundtrip_preserves_admin_and_account() { + use alloy_rlp::Decodable; + + let account = target_addr(0xAB); + let mut args = key_auth_args(); + args.admin = true; + let authorization = args.into_authorization(Some(account)).unwrap(); + assert!(authorization.is_admin()); + assert_eq!(authorization.account, Some(account)); + + let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap()); + let encoded = encode_key_authorization(&signed); + let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap(); + assert!(decoded.authorization.is_admin()); + assert_eq!(decoded.authorization.account, Some(account)); + } + + #[test] + fn test_account_bound_non_admin_roundtrip() { + use alloy_rlp::Decodable; + + let account = target_addr(0xCD); + let authorization = key_auth_args().into_authorization(Some(account)).unwrap(); + assert!(!authorization.is_admin()); + assert_eq!(authorization.account, Some(account)); + + let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap()); + let encoded = encode_key_authorization(&signed); + let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap(); + assert!(!decoded.authorization.is_admin()); + assert_eq!(decoded.authorization.account, Some(account)); + } + + #[test] + fn test_non_admin_authorization_omits_t6_fields() { + // Backward compatibility: a plain authorization must not carry admin/account. + let authorization = key_auth_args().into_authorization(None).unwrap(); + assert!(!authorization.is_admin()); + assert_eq!(authorization.account, None); + assert!(authorization.is_legacy_compatible()); + } + + #[test] + fn test_admin_account_binding_changes_signature_hash() { + let mut admin_a = key_auth_args(); + admin_a.admin = true; + let auth_a = admin_a.into_authorization(Some(target_addr(0x01))).unwrap(); + + let mut admin_b = key_auth_args(); + admin_b.admin = true; + let auth_b = admin_b.into_authorization(Some(target_addr(0x02))).unwrap(); + + // Account binding feeds the signing hash so a signature cannot be replayed across accounts. + assert_ne!(auth_a.signature_hash(), auth_b.signature_hash()); + } + + #[test] + fn test_admin_requires_account() { + let mut args = key_auth_args(); + args.admin = true; + let err = args.into_authorization(None).unwrap_err().to_string(); + assert!(err.contains("--admin requires --account"), "got: {err}"); + } + + #[test] + fn test_admin_rejects_expiry_limits_and_scopes() { + let account = target_addr(0xAB); + + let mut expiry = key_auth_args(); + expiry.admin = true; + expiry.expiry = Some(1_782_647_677); + assert!( + expiry.into_authorization(Some(account)).unwrap_err().to_string().contains("--expiry"), + "expiry must be rejected for admin keys" + ); + + let mut limits = key_auth_args(); + limits.admin = true; + limits.enforce_limits = true; + assert!( + limits + .into_authorization(Some(account)) + .unwrap_err() + .to_string() + .contains("spending limits"), + "spending limits must be rejected for admin keys" + ); + + let mut scopes = key_auth_args(); + scopes.admin = true; + scopes.scopes_json = Some(AuthScopesJson(vec![])); + assert!( + scopes + .into_authorization(Some(account)) + .unwrap_err() + .to_string() + .contains("call scopes"), + "call scopes must be rejected for admin keys" + ); + } + + #[test] + fn test_account_zero_is_rejected() { + let err = key_auth_args().into_authorization(Some(Address::ZERO)).unwrap_err().to_string(); + assert!(err.contains("--account cannot be the zero address"), "got: {err}"); + } + + /// Hex-encode a signed admin authorization bound to `account` for inspect tests. + fn signed_admin_auth_hex(account: Address) -> String { + let mut args = key_auth_args(); + args.admin = true; + let authorization = args.into_authorization(Some(account)).unwrap(); + let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap()); + hex::encode_prefixed(encode_key_authorization(&signed)) + } + + #[test] + fn test_inspect_decodes_signed_and_unsigned_shapes() { + // Signed admin authorization: reported as signed with its T6 fields exposed. + let account = target_addr(0xAB); + let (auth, signed, _signer) = + decode_and_validate_key_authorization(&signed_admin_auth_hex(account), None).unwrap(); + assert!(signed, "signed input must be reported as signed"); + assert!(auth.is_admin()); + assert_eq!(auth.account, Some(account)); + + // Unsigned authorization: the other RLP shape decodes with no signer. + let unsigned = key_auth_args().into_authorization(None).unwrap(); + let hex = hex::encode_prefixed(encode_key_authorization(&unsigned)); + let (auth, signed, signer) = decode_and_validate_key_authorization(&hex, None).unwrap(); + assert!(!signed, "unsigned input must be reported as unsigned"); + assert!(!auth.is_admin()); + assert_eq!(auth.account, None); + assert!(signer.is_none(), "unsigned input must not recover a signer"); + } + + #[test] + fn test_inspect_account_mismatch_is_rejected() { + let account = target_addr(0xAB); + let hex = signed_admin_auth_hex(account); + let err = decode_and_validate_key_authorization(&hex, Some(target_addr(0xCD))) + .unwrap_err() + .to_string(); + assert!(err.contains("is bound to account") && err.contains("was expected"), "got: {err}"); + } + + #[test] + fn test_inspect_rejects_admin_auth_carrying_restrictions() { + // Build an admin authorization that carries an expiry directly (bypassing the CLI + // constructor's guard) to prove `inspect` mirrors the chain's admin invariants. + let account = target_addr(0xAB); + let authorization = + KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42)) + .with_expiry(1_782_647_677) + .into_admin(account); + let hex = hex::encode_prefixed(encode_key_authorization(&authorization)); + + let err = decode_and_validate_key_authorization(&hex, None).unwrap_err().to_string(); + assert!(err.contains("cannot carry an expiry"), "got: {err}"); + } + + #[test] + fn test_inspect_accepts_root_signed_admin_auth_without_account() { + // T6 allows a root-signed admin authorization to omit `account` (account is only required + // when the signer is not the target root). `inspect` is a decoder and must not reject it. + let mut authorization = + KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42)); + authorization.is_admin = true; + let hex = hex::encode_prefixed(encode_key_authorization(&authorization)); + + let (auth, _signed, _signer) = + decode_and_validate_key_authorization(&hex, None).expect("admin auth may omit account"); + assert!(auth.is_admin()); + assert_eq!(auth.account, None); + } + + #[test] + fn test_inspect_admin_auth_without_account_rejected_when_account_expected() { + // When the caller supplies `--account`, an admin authorization that omits `account` must + // still be rejected: the binding cannot be verified. + let mut authorization = + KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42)); + authorization.is_admin = true; + let hex = hex::encode_prefixed(encode_key_authorization(&authorization)); + + let err = decode_and_validate_key_authorization(&hex, Some(target_addr(0xAB))) + .unwrap_err() + .to_string(); + assert!(err.contains("no account field"), "got: {err}"); + } + + #[test] + fn test_local_key_role_classification() { + let wallet = target_addr(0x01); + let key = target_addr(0x02); + + let root = tempo::KeyEntry { + wallet_address: wallet, + key_address: Some(wallet), + ..Default::default() + }; + assert_eq!(local_key_role(&root, false), "root"); + + let access = tempo::KeyEntry { + wallet_address: wallet, + key_address: Some(key), + ..Default::default() + }; + assert_eq!(local_key_role(&access, false), "limited"); + assert_eq!(local_key_role(&access, true), "admin"); + } + #[test] fn test_key_auth_encode_preserves_explicit_empty_scopes_json() { - let absent = key_auth_args().into_authorization().unwrap(); + let absent = key_auth_args().into_authorization(None).unwrap(); let mut args = key_auth_args(); args.scopes_json = Some(AuthScopesJson(vec![])); - let deny_all = args.into_authorization().unwrap(); + let deny_all = args.into_authorization(None).unwrap(); assert_eq!(absent.allowed_calls, None); assert_eq!(deny_all.allowed_calls, Some(vec![])); @@ -3996,7 +4714,7 @@ mod tests { fn test_key_auth_encode_rejects_zero_expiry() { let mut args = key_auth_args(); args.expiry = Some(0); - let err = args.into_authorization().unwrap_err(); + let err = args.into_authorization(None).unwrap_err(); assert!( err.to_string().contains("--expiry must be greater than zero"), "unexpected error: {err}" diff --git a/crates/cast/src/cmd/mod.rs b/crates/cast/src/cmd/mod.rs index 865a67af24882..04495599d0113 100644 --- a/crates/cast/src/cmd/mod.rs +++ b/crates/cast/src/cmd/mod.rs @@ -25,6 +25,7 @@ pub mod keychain; pub mod logs; pub(crate) mod miner; pub mod mktx; +pub mod receive_policy; pub mod rpc; pub mod run; pub mod send; diff --git a/crates/cast/src/cmd/receive_policy.rs b/crates/cast/src/cmd/receive_policy.rs new file mode 100644 index 0000000000000..e3c1e7025c65b --- /dev/null +++ b/crates/cast/src/cmd/receive_policy.rs @@ -0,0 +1,784 @@ +use crate::{ + cmd::tip20::{resolve_tip20_signer, send_tip20_transaction}, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; +use clap::{Parser, Subcommand}; +use eyre::{Result, WrapErr, ensure}; +use foundry_cli::{ + json::print_json_success, + opts::RpcOpts, + utils::{LoadConfig, get_provider}, +}; +use foundry_common::{provider::ProviderBuilder, shell}; +use foundry_evm_networks::TEMPO_PRECOMPILE_ADDRESSES; +use serde_json::{Value, json}; +use std::str::FromStr; +use tempo_alloy::TempoNetwork; +use tempo_contracts::precompiles::{ + ADDRESS_REGISTRY_ADDRESS, IAddressRegistry, IReceivePolicyGuard, ITIP403Registry, + RECEIVE_POLICY_GUARD_ADDRESS, TIP403_REGISTRY_ADDRESS, +}; +use tempo_primitives::TempoAddressExt; + +/// Account-level receive policy operations (Tempo). +#[derive(Debug, Parser, Clone)] +pub enum ReceivePolicySubcommand { + /// Set the caller's TIP-403 receive policy. + Set { + /// Sender policy ID to evaluate for inbound transfer originators. + sender_policy_id: u64, + + /// Token filter policy ID to evaluate for inbound TIP-20 tokens. + token_filter_id: u64, + + /// Address authorized to recover held receipts. Defaults to originator recovery. + #[arg(long, value_name = "ADDRESS", default_value_t = Address::ZERO)] + recovery_authority: Address, + + /// Print the calldata and receive-policy warning without sending a transaction. + #[arg(long, visible_alias = "dry-run")] + preview: bool, + + /// Suppress the originator-recovery/system-sender warning. + #[arg(long)] + force: bool, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Get an account's configured receive policy. + Get { + /// Account whose receive policy should be queried. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Validate whether an inbound TIP-20 transfer or mint would be credited or held. + Validate { + /// TIP-20 token address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// Inbound transfer sender or mint originator. + #[arg(value_parser = NameOrAddress::from_str)] + sender: NameOrAddress, + + /// Intended recipient. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Blocked receive-policy receipt utilities. + Receipt { + #[command(subcommand)] + command: ReceivePolicyReceiptSubcommand, + }, + + /// Claim held TIP-20 funds using a blocked receive-policy receipt. + Claim { + /// Desired release target. The guard decides onchain whether to resume or reroute. + #[arg(value_parser = NameOrAddress::from_str)] + to: NameOrAddress, + + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, +} + +#[derive(Debug, Subcommand, Clone)] +pub enum ReceivePolicyReceiptSubcommand { + /// Decode an ABI-encoded ReceivePolicyGuard claim receipt. + Decode { + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + }, + + /// Query the held TIP-20 balance for a claim receipt. + Balance { + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Burn held funds for a blocked receipt when authorized by the token. + Burn { + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + + #[command(flatten)] + send_tx: Box, + + #[command(flatten)] + tx: Box, + }, +} + +impl ReceivePolicySubcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Set { + sender_policy_id, + token_filter_id, + recovery_authority, + preview, + force, + send_tx, + tx, + } => { + set( + sender_policy_id, + token_filter_id, + recovery_authority, + preview, + force, + send_tx, + tx, + ) + .await? + } + Self::Get { account, rpc } => get(account, rpc).await?, + Self::Validate { token, sender, receiver, rpc } => { + validate(token, sender, receiver, rpc).await? + } + Self::Receipt { command } => match command { + ReceivePolicyReceiptSubcommand::Decode { receipt } => decode_receipt(receipt)?, + ReceivePolicyReceiptSubcommand::Balance { receipt, rpc } => { + receipt_balance(receipt, rpc).await? + } + ReceivePolicyReceiptSubcommand::Burn { receipt, send_tx, tx } => { + burn_receipt(receipt, *send_tx, *tx).await? + } + }, + Self::Claim { to, receipt, send_tx, tx } => claim(to, receipt, send_tx, tx).await?, + } + + Ok(()) + } +} + +async fn set( + sender_policy_id: u64, + token_filter_id: u64, + recovery_authority: Address, + preview: bool, + force: bool, + send_tx: SendTxOpts, + tx: TxParams, +) -> Result<()> { + let warning = if force { + None + } else { + recovery_warning(sender_policy_id, recovery_authority, &send_tx.eth.rpc).await? + }; + + let call = ITIP403Registry::setReceivePolicyCall { + senderPolicyId: sender_policy_id, + tokenFilterId: token_filter_id, + recoveryAuthority: recovery_authority, + }; + let calldata = Bytes::from(call.abi_encode()); + + if preview { + let payload = json!({ + "action": "set_receive_policy", + "registry": format!("{TIP403_REGISTRY_ADDRESS}"), + "sender_policy_id": sender_policy_id, + "token_filter_id": token_filter_id, + "recovery_authority": format!("{recovery_authority}"), + "recovery_mode": recovery_mode(recovery_authority), + "calldata": format!("{calldata}"), + "warning": warning, + }); + if shell::is_json() { + print_json_success(payload)?; + } else { + sh_println!( + "Registry: {TIP403_REGISTRY_ADDRESS}\n\ + Sender policy ID: {sender_policy_id}\n\ + Token filter ID: {token_filter_id}\n\ + Recovery authority: {recovery_authority}\n\ + Recovery mode: {}\n\ + Calldata: {calldata}", + recovery_mode(recovery_authority) + )?; + if let Some(warning) = warning.as_deref() { + sh_warn!("{warning}")?; + } + } + return Ok(()); + } + + if let Some(warning) = warning.as_deref() { + sh_warn!("{warning}")?; + } + + let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?; + send_tip20_transaction( + NameOrAddress::Address(TIP403_REGISTRY_ADDRESS), + "setReceivePolicy(uint64,uint64,address)", + vec![ + sender_policy_id.to_string(), + token_filter_id.to_string(), + recovery_authority.to_string(), + ], + send_tx, + tx, + signer, + access_key, + ) + .await +} + +async fn get(account: NameOrAddress, rpc: RpcOpts) -> Result<()> { + let config = rpc.load_config()?; + let provider = get_provider(&config)?; + let account = account.resolve(&provider).await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let policy = registry.receivePolicy(account).call().await?; + + let payload = json!({ + "account": format!("{account}"), + "has_receive_policy": policy.hasReceivePolicy, + "sender_policy_id": policy.senderPolicyId, + "sender_policy_type": policy_type(policy.senderPolicyType), + "token_filter_id": policy.tokenFilterId, + "token_filter_type": policy_type(policy.tokenFilterType), + "recovery_authority": format!("{}", policy.recoveryAuthority), + "recovery_mode": recovery_mode(policy.recoveryAuthority), + }); + print_payload(payload, |payload| { + sh_println!( + "Account: {}\n\ + Has receive policy: {}\n\ + Sender policy ID: {}\n\ + Sender policy type: {}\n\ + Token filter ID: {}\n\ + Token filter type: {}\n\ + Recovery authority: {}\n\ + Recovery mode: {}", + payload["account"].as_str().unwrap_or_default(), + payload["has_receive_policy"].as_bool().unwrap_or_default(), + payload["sender_policy_id"], + payload["sender_policy_type"].as_str().unwrap_or_default(), + payload["token_filter_id"], + payload["token_filter_type"].as_str().unwrap_or_default(), + payload["recovery_authority"].as_str().unwrap_or_default(), + payload["recovery_mode"].as_str().unwrap_or_default(), + ) + }) +} + +async fn validate( + token: NameOrAddress, + sender: NameOrAddress, + receiver: NameOrAddress, + rpc: RpcOpts, +) -> Result<()> { + let config = rpc.load_config()?; + let provider = get_provider(&config)?; + let token = token.resolve(&provider).await?; + let sender = sender.resolve(&provider).await?; + let receiver = receiver.resolve(&provider).await?; + let effective_receiver = IAddressRegistry::new(ADDRESS_REGISTRY_ADDRESS, &provider) + .resolveRecipient(receiver) + .call() + .await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let result = registry.validateReceivePolicy(token, sender, effective_receiver).call().await?; + let delivery_state = if result.authorized { "credited" } else { "held" }; + + let payload = validate_payload( + token, + sender, + receiver, + effective_receiver, + result.authorized, + result.blockedReason, + delivery_state, + ); + print_payload(payload, |payload| { + sh_println!( + "Token: {}\n\ + Sender: {}\n\ + Receiver: {}\n\ + Effective recv: {}\n\ + Authorized: {}\n\ + Blocked reason: {}\n\ + Delivery state: {}", + payload["token"].as_str().unwrap_or_default(), + payload["sender"].as_str().unwrap_or_default(), + payload["receiver"].as_str().unwrap_or_default(), + payload["effective_receiver"].as_str().unwrap_or_default(), + payload["authorized"].as_bool().unwrap_or_default(), + payload["blocked_reason"].as_str().unwrap_or_default(), + payload["delivery_state"].as_str().unwrap_or_default(), + ) + }) +} + +fn validate_payload( + token: Address, + sender: Address, + receiver: Address, + effective_receiver: Address, + authorized: bool, + blocked_reason_value: ITIP403Registry::BlockedReason, + delivery_state: &str, +) -> Value { + json!({ + "token": format!("{token}"), + "sender": format!("{sender}"), + "receiver": format!("{receiver}"), + "effective_receiver": format!("{effective_receiver}"), + "receiver_was_resolved": receiver != effective_receiver, + "authorized": authorized, + "blocked_reason": blocked_reason(blocked_reason_value), + "delivery_state": delivery_state, + }) +} + +fn decode_receipt(receipt: Bytes) -> Result<()> { + let decoded = decode_claim_receipt(&receipt)?; + let payload = receipt_payload(&receipt, &decoded, None); + print_payload(payload, |payload| { + print_decoded_receipt(payload)?; + print_claim_hint(payload) + }) +} + +async fn receipt_balance(receipt: Bytes, rpc: RpcOpts) -> Result<()> { + let config = rpc.load_config()?; + let provider = get_provider(&config)?; + let guard = IReceivePolicyGuard::new(RECEIVE_POLICY_GUARD_ADDRESS, provider); + let amount = guard.balanceOf(receipt.clone()).call().await?; + let decoded = decode_claim_receipt(&receipt)?; + let payload = receipt_payload(&receipt, &decoded, Some(amount)); + print_payload(payload, |payload| { + print_decoded_receipt(payload)?; + sh_println!("Held balance: {}", payload["held_balance"].as_str().unwrap_or_default()) + }) +} + +async fn burn_receipt(receipt: Bytes, send_tx: SendTxOpts, tx: TxParams) -> Result<()> { + decode_claim_receipt(&receipt)?; + let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?; + send_tip20_transaction( + NameOrAddress::Address(RECEIVE_POLICY_GUARD_ADDRESS), + "burnBlockedReceipt(bytes)", + vec![format!("{receipt}")], + send_tx, + tx, + signer, + access_key, + ) + .await +} + +async fn claim(to: NameOrAddress, receipt: Bytes, send_tx: SendTxOpts, tx: TxParams) -> Result<()> { + decode_claim_receipt(&receipt)?; + let config = send_tx.eth.rpc.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let to = to.resolve(&provider).await?; + let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?; + send_tip20_transaction( + NameOrAddress::Address(RECEIVE_POLICY_GUARD_ADDRESS), + "claim(address,bytes)", + vec![to.to_string(), format!("{receipt}")], + send_tx, + tx, + signer, + access_key, + ) + .await +} + +async fn recovery_warning( + sender_policy_id: u64, + recovery_authority: Address, + rpc: &RpcOpts, +) -> Result> { + if recovery_authority != Address::ZERO { + return Ok(None); + } + + let config = rpc.load_config()?; + let provider = get_provider(&config)?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let mut blocked = Vec::new(); + for address in TEMPO_PRECOMPILE_ADDRESSES { + if !registry.isAuthorizedSender(sender_policy_id, *address).call().await.unwrap_or(true) { + blocked.push(*address); + } + } + + if blocked.is_empty() { + return Ok(None); + } + + Ok(Some(format!( + "originator recovery is enabled because recovery authority is 0x0, but sender policy \ + {sender_policy_id} blocks {} Tempo system/precompile sender(s): {}. Receipts created \ + for those senders may not be claimable by a user. Choose receiver or third-party \ + recovery authority when blocking system senders, or pass --force if this is intentional.", + blocked.len(), + blocked.iter().map(Address::to_string).collect::>().join(", ") + ))) +} + +fn decode_claim_receipt(receipt: &Bytes) -> Result { + let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(receipt) + .wrap_err("invalid ReceivePolicyGuard claim receipt")?; + + ensure!( + decoded.version == 1, + "unsupported ReceivePolicyGuard claim receipt version {}", + decoded.version + ); + ensure!(decoded.token != Address::ZERO, "ReceivePolicyGuard claim receipt token is zero"); + ensure!( + decoded.recipient != RECEIVE_POLICY_GUARD_ADDRESS, + "ReceivePolicyGuard claim receipt recipient cannot be the guard precompile" + ); + ensure!( + matches!( + decoded.blockedReason, + reason if reason == ITIP403Registry::BlockedReason::TOKEN_FILTER as u8 || + reason == ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8 + ), + "ReceivePolicyGuard claim receipt blocked reason is not claimable" + ); + ensure!( + matches!( + decoded.kind, + IReceivePolicyGuard::InboundKind::TRANSFER | IReceivePolicyGuard::InboundKind::MINT + ), + "ReceivePolicyGuard claim receipt inbound kind is unknown" + ); + + Ok(decoded) +} + +fn receipt_payload( + receipt: &Bytes, + decoded: &IReceivePolicyGuard::ClaimReceiptV1, + amount: Option, +) -> Value { + let receipt_key = keccak256(receipt); + let delivery_state = match amount { + Some(amount) if amount > U256::ZERO => "held", + Some(_) => "not_held", + None => "unknown", + }; + let mut payload = json!({ + "receipt": format!("{receipt}"), + "receipt_key": format!("{receipt_key}"), + "version": decoded.version, + "token": format!("{}", decoded.token), + "recovery_authority": format!("{}", decoded.recoveryAuthority), + "recovery_mode": recovery_mode(decoded.recoveryAuthority), + "originator": format!("{}", decoded.originator), + "recipient": format!("{}", decoded.recipient), + "recipient_is_virtual": decoded.recipient.is_virtual(), + "claim_target": if decoded.recipient.is_virtual() || decoded.recoveryAuthority == Address::ZERO { + Value::Null + } else { + json!(format!("{}", decoded.recipient)) + }, + "blocked_at": decoded.blockedAt, + "blocked_nonce": decoded.blockedNonce, + "blocked_reason": blocked_reason_u8(decoded.blockedReason), + "kind": inbound_kind(decoded.kind), + "memo": format!("{}", decoded.memo), + "delivery_state": delivery_state, + }); + if let Some(amount) = amount { + payload["held_balance"] = json!(amount.to_string()); + } + payload +} + +fn print_decoded_receipt(payload: &Value) -> Result<()> { + sh_println!( + "Receipt key: {}\n\ + Token: {}\n\ + Recovery authority: {}\n\ + Recovery mode: {}\n\ + Originator: {}\n\ + Recipient: {}\n\ + Blocked at: {}\n\ + Blocked nonce: {}\n\ + Blocked reason: {}\n\ + Kind: {}\n\ + Memo: {}\n\ + Delivery state: {}", + payload["receipt_key"].as_str().unwrap_or_default(), + payload["token"].as_str().unwrap_or_default(), + payload["recovery_authority"].as_str().unwrap_or_default(), + payload["recovery_mode"].as_str().unwrap_or_default(), + payload["originator"].as_str().unwrap_or_default(), + payload["recipient"].as_str().unwrap_or_default(), + payload["blocked_at"], + payload["blocked_nonce"], + payload["blocked_reason"].as_str().unwrap_or_default(), + payload["kind"].as_str().unwrap_or_default(), + payload["memo"].as_str().unwrap_or_default(), + payload["delivery_state"].as_str().unwrap_or_default(), + ) +} + +fn print_claim_hint(payload: &Value) -> Result<()> { + let recipient = payload["recipient"].as_str().unwrap_or_default(); + let receipt = payload["receipt"].as_str().unwrap_or_default(); + if payload["recovery_mode"].as_str() == Some("originator") { + sh_println!( + "\nClaim target: originator recovery reroutes funds, so do not default to the blocked recipient. Claim to an address that can receive the token:\n cast receive-policy claim {receipt}" + ) + } else if payload["recipient_is_virtual"].as_bool().unwrap_or_default() { + sh_println!( + "\nClaim target: recipient is a virtual address; resolve it first with:\n cast vaddr resolve {recipient}\nThen claim to the registered master address:\n cast receive-policy claim {receipt}" + ) + } else { + sh_println!("\nClaim path: cast receive-policy claim {recipient} {receipt}") + } +} + +fn print_payload(payload: Value, human: F) -> Result<()> +where + F: FnOnce(&Value) -> Result<()>, +{ + if shell::is_json() { + print_json_success(payload)?; + } else { + human(&payload)?; + } + Ok(()) +} + +fn recovery_mode(recovery_authority: Address) -> &'static str { + if recovery_authority == Address::ZERO { "originator" } else { "authority" } +} + +const fn policy_type(policy_type: ITIP403Registry::PolicyType) -> &'static str { + match policy_type { + ITIP403Registry::PolicyType::WHITELIST => "whitelist", + ITIP403Registry::PolicyType::BLACKLIST => "blacklist", + ITIP403Registry::PolicyType::COMPOUND => "compound", + _ => "unknown", + } +} + +const fn blocked_reason(reason: ITIP403Registry::BlockedReason) -> &'static str { + match reason { + ITIP403Registry::BlockedReason::NONE => "none", + ITIP403Registry::BlockedReason::TOKEN_FILTER => "token_filter", + ITIP403Registry::BlockedReason::RECEIVE_POLICY => "receive_policy", + _ => "unknown", + } +} + +const fn blocked_reason_u8(reason: u8) -> &'static str { + match reason { + 0 => "none", + 1 => "token_filter", + 2 => "receive_policy", + _ => "unknown", + } +} + +const fn inbound_kind(kind: IReceivePolicyGuard::InboundKind) -> &'static str { + match kind { + IReceivePolicyGuard::InboundKind::TRANSFER => "transfer", + IReceivePolicyGuard::InboundKind::MINT => "mint", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, b256}; + use tempo_primitives::{MasterId, UserTag}; + + fn sample_receipt() -> Bytes { + IReceivePolicyGuard::ClaimReceiptV1::new( + address!("0000000000000000000000000000000000000010"), + address!("0000000000000000000000000000000000000020"), + address!("0000000000000000000000000000000000000030"), + address!("0000000000000000000000000000000000000040"), + 1_780_000_000, + 7, + ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8, + IReceivePolicyGuard::InboundKind::TRANSFER, + b256!("0000000000000000000000000000000000000000000000000000000000000042"), + ) + .abi_encode() + .into() + } + + #[test] + fn decodes_guard_claim_receipt() { + let receipt = sample_receipt(); + let decoded = decode_claim_receipt(&receipt).unwrap(); + assert_eq!(decoded.version, 1); + assert_eq!(decoded.token, address!("0000000000000000000000000000000000000010")); + assert_eq!(decoded.recoveryAuthority, address!("0000000000000000000000000000000000000020")); + assert_eq!(decoded.originator, address!("0000000000000000000000000000000000000030")); + assert_eq!(decoded.recipient, address!("0000000000000000000000000000000000000040")); + assert_eq!(decoded.blockedNonce, 7); + assert_eq!(decoded.kind, IReceivePolicyGuard::InboundKind::TRANSFER); + } + + #[test] + fn rejects_invalid_guard_claim_receipt() { + let err = decode_claim_receipt(&Bytes::from_static(&[0xde, 0xad])).unwrap_err(); + assert!(err.to_string().contains("invalid ReceivePolicyGuard claim receipt")); + } + + #[test] + fn rejects_semantically_invalid_guard_claim_receipts() { + let receipt = sample_receipt(); + let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(&receipt).unwrap(); + + let mut bad_version = decoded.clone(); + bad_version.version = 2; + let err = decode_claim_receipt(&bad_version.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("unsupported ReceivePolicyGuard claim receipt version")); + + let mut bad_token = decoded.clone(); + bad_token.token = Address::ZERO; + let err = decode_claim_receipt(&bad_token.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("token is zero")); + + let mut bad_recipient = decoded.clone(); + bad_recipient.recipient = RECEIVE_POLICY_GUARD_ADDRESS; + let err = decode_claim_receipt(&bad_recipient.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("recipient cannot be the guard precompile")); + + let mut bad_reason = decoded; + bad_reason.blockedReason = ITIP403Registry::BlockedReason::NONE as u8; + let err = decode_claim_receipt(&bad_reason.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("blocked reason is not claimable")); + } + + #[test] + fn validate_payload_records_effective_receiver() { + let receiver = Address::new_virtual( + MasterId::from([0x12, 0x34, 0x56, 0x78]), + UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]), + ); + let effective_receiver = address!("0000000000000000000000000000000000000040"); + + let payload = validate_payload( + address!("0000000000000000000000000000000000000010"), + address!("0000000000000000000000000000000000000030"), + receiver, + effective_receiver, + false, + ITIP403Registry::BlockedReason::RECEIVE_POLICY, + "held", + ); + + assert_eq!(payload["receiver"], format!("{receiver}")); + assert_eq!(payload["effective_receiver"], format!("{effective_receiver}")); + assert_eq!(payload["receiver_was_resolved"], true); + assert_eq!(payload["authorized"], false); + assert_eq!(payload["blocked_reason"], "receive_policy"); + assert_eq!(payload["delivery_state"], "held"); + } + + #[test] + fn receipt_payload_preserves_delivery_state_confidence() { + let receipt = sample_receipt(); + let decoded = decode_claim_receipt(&receipt).unwrap(); + + let unknown = receipt_payload(&receipt, &decoded, None); + assert_eq!(unknown["delivery_state"], "unknown"); + + let held = receipt_payload(&receipt, &decoded, Some(U256::from(1))); + assert_eq!(held["delivery_state"], "held"); + assert_eq!(held["blocked_reason"], "receive_policy"); + assert_eq!(held["kind"], "transfer"); + assert_eq!(held["held_balance"], "1"); + assert_eq!(held["recipient_is_virtual"], false); + assert_eq!(held["claim_target"], format!("{}", decoded.recipient)); + + let not_held = receipt_payload(&receipt, &decoded, Some(U256::ZERO)); + assert_eq!(not_held["delivery_state"], "not_held"); + assert_eq!(not_held["held_balance"], "0"); + } + + #[test] + fn virtual_receipt_recipient_requires_resolved_claim_target() { + let receipt = sample_receipt(); + let mut decoded = decode_claim_receipt(&receipt).unwrap(); + decoded.recipient = Address::new_virtual( + MasterId::from([0x12, 0x34, 0x56, 0x78]), + UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]), + ); + + let payload = receipt_payload(&receipt, &decoded, None); + assert_eq!(payload["recipient"], format!("{}", decoded.recipient)); + assert_eq!(payload["recipient_is_virtual"], true); + assert_eq!(payload["claim_target"], Value::Null); + } + + #[test] + fn originator_recovery_receipt_requires_explicit_claim_target() { + let receipt = sample_receipt(); + let mut decoded = decode_claim_receipt(&receipt).unwrap(); + decoded.recoveryAuthority = Address::ZERO; + + let payload = receipt_payload(&receipt, &decoded, None); + assert_eq!(payload["recovery_mode"], "originator"); + assert_eq!(payload["recipient_is_virtual"], false); + assert_eq!(payload["claim_target"], Value::Null); + } + + #[test] + fn originator_recovery_takes_precedence_over_virtual_recipient() { + let receipt = sample_receipt(); + let mut decoded = decode_claim_receipt(&receipt).unwrap(); + decoded.recoveryAuthority = Address::ZERO; + decoded.recipient = Address::new_virtual( + MasterId::from([0x12, 0x34, 0x56, 0x78]), + UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]), + ); + + let payload = receipt_payload(&receipt, &decoded, None); + assert_eq!(payload["recovery_mode"], "originator"); + assert_eq!(payload["recipient_is_virtual"], true); + assert_eq!(payload["claim_target"], Value::Null); + } + + #[test] + fn preview_calldata_uses_set_receive_policy_selector() { + let call = ITIP403Registry::setReceivePolicyCall { + senderPolicyId: 0, + tokenFilterId: 1, + recoveryAuthority: Address::ZERO, + }; + let calldata = call.abi_encode(); + assert_eq!(&calldata[..4], ITIP403Registry::setReceivePolicyCall::SELECTOR); + } +} diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index f7ef22dfcda0d..4da3798b80d8e 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -18,7 +18,7 @@ use foundry_common::{ FoundryTransactionBuilder, fmt::{UIfmt, UIfmtReceiptExt}, provider::ProviderBuilder, - tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_resolved_fee_token}, + tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_fee_token, resolve_and_set_fee_token}, }; use foundry_config::Chain; use foundry_wallets::{TempoAccessKeyConfig, WalletSigner}; @@ -142,6 +142,7 @@ impl SendTxArgs { let print_sponsor_hash = tx.tempo.print_sponsor_hash; let sponsor_url = tx.tempo.sponsor_url.clone(); + let sponsor_fee_payer = tx.tempo.sponsor; let expires_at = tx.tempo.resolve_expires(); let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() { None @@ -242,7 +243,8 @@ impl SendTxArgs { // If --tempo.print-sponsor-hash was passed, build the tx, print the hash, and exit. if print_sponsor_hash { - let (tx, from) = if let Some(ref ak) = access_key { + let chain = builder.chain(); + let (mut tx, from) = if let Some(ref ak) = access_key { let (tx, _) = builder.build_with_access_key(ak.wallet_address, ak).await?; (tx, ak.wallet_address) } else { @@ -255,6 +257,15 @@ impl SendTxArgs { let (tx, _) = builder.build(from).await?; (tx, from) }; + if let Some(fee_payer) = sponsor_fee_payer { + resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + Some(fee_payer), + ) + .await?; + } let hash = tx .compute_sponsor_hash(from) .ok_or_else(|| eyre!("This network does not support sponsored transactions"))?; @@ -317,18 +328,26 @@ impl SendTxArgs { tx_request.nonce().unwrap_or_default(), )?; if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx_request, + ) + .await?; sponsor.attach_and_print::(&mut tx_request, config.sender).await?; } cast_send( provider, tx_request, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; // Case 2: @@ -351,14 +370,25 @@ impl SendTxArgs { tx_request.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER); } if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx_request, + ) + .await?; sponsor.attach_and_print::(&mut tx_request, browser.address()).await?; + } else { + let fee_token = resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx_request, + Some(browser.address()), + ) + .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token) + .await?; } - maybe_print_resolved_fee_token( - (!config.eth_rpc_curl).then_some(&provider), - Some(chain), - tx_request.fee_token(), - ) - .await?; let tx_hash = browser.send_transaction_via_browser(tx_request).await?; @@ -380,6 +410,13 @@ impl SendTxArgs { tx_request.nonce().unwrap_or_default(), )?; if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx_request, + ) + .await?; sponsor.attach_and_print::(&mut tx_request, ak.wallet_address).await?; } cast_send_with_access_key( @@ -387,11 +424,12 @@ impl SendTxArgs { tx_request, &signer, &ak, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, send_tx.cast_async, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; // Case 4: @@ -406,7 +444,6 @@ impl SendTxArgs { tx::validate_from_address(send_tx.eth.wallet.from, from)?; - let chain = builder.chain(); let (mut tx_request, _) = builder.build(&signer).await?; maybe_print_resolved_lane( resolved_lane.as_ref(), @@ -429,12 +466,13 @@ impl SendTxArgs { cast_send( provider, tx_request, - Some(chain), + None, + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + false, ) .await?; // Case 5: @@ -458,6 +496,13 @@ impl SendTxArgs { )?; if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx_request, + ) + .await?; sponsor.attach_and_print::(&mut tx_request, from).await?; } @@ -469,12 +514,13 @@ impl SendTxArgs { cast_send( provider, tx_request, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; } @@ -486,8 +532,9 @@ impl SendTxArgs { #[allow(clippy::too_many_arguments)] pub(crate) async fn cast_send>( provider: P, - tx: N::TransactionRequest, + mut tx: N::TransactionRequest, chain: Option, + fee_payer: Option
, cast_async: bool, sync: bool, confs: u64, @@ -498,12 +545,14 @@ where N::TransactionRequest: Default + FoundryTransactionBuilder, N::ReceiptResponse: UIfmt + UIfmtReceiptExt, { - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( resolve_unknown_fee_token_symbol.then_some(&provider), chain, - tx.fee_token(), + &mut tx, + fee_payer, ) .await?; + maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(&provider), fee_token).await?; let cast = CastTxSender::new(provider); if sync { @@ -533,6 +582,7 @@ pub(crate) async fn cast_send_with_access_key>( signer: &WalletSigner, access_key: &TempoAccessKeyConfig, chain: Option, + fee_payer: Option
, cast_async: bool, confirmations: u64, timeout: u64, @@ -544,12 +594,14 @@ where { tx.set_from(access_key.wallet_address); tx.set_key_id(access_key.key_address); - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( resolve_unknown_fee_token_symbol.then_some(provider), chain, - tx.fee_token(), + &mut tx, + fee_payer, ) .await?; + maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(provider), fee_token).await?; let raw_tx = tx .sign_with_access_key( provider, diff --git a/crates/cast/src/cmd/tip20/mine.rs b/crates/cast/src/cmd/tip20/mine.rs index 7d38c4c8ef74d..f8039f330d47f 100644 --- a/crates/cast/src/cmd/tip20/mine.rs +++ b/crates/cast/src/cmd/tip20/mine.rs @@ -121,6 +121,7 @@ pub(super) async fn register( &signer, access_key, Some(chain), + None, send_tx.cast_async, send_tx.confirmations, timeout, @@ -133,6 +134,7 @@ pub(super) async fn register( provider, tx, Some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, diff --git a/crates/cast/src/cmd/tip20/mod.rs b/crates/cast/src/cmd/tip20/mod.rs index 269f5a73c5dd2..3a20f294c8514 100644 --- a/crates/cast/src/cmd/tip20/mod.rs +++ b/crates/cast/src/cmd/tip20/mod.rs @@ -17,7 +17,7 @@ use foundry_cli::{ use foundry_common::{ FoundryTransactionBuilder, provider::ProviderBuilder, - tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_resolved_fee_token}, + tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_fee_token, resolve_and_set_fee_token}, }; use foundry_wallets::{TempoAccessKeyConfig, WalletSigner}; use std::{str::FromStr, time::Duration}; @@ -180,7 +180,7 @@ impl Tip20Subcommand { } } -pub(super) async fn resolve_tip20_signer( +pub(crate) async fn resolve_tip20_signer( send_tx: &SendTxOpts, tx_params: &TxParams, ) -> eyre::Result<(Option, Option)> { @@ -196,7 +196,7 @@ pub(super) async fn resolve_tip20_signer( tempo::resolve_session_or_wallet_signer(&tx_params.tempo, &send_tx.eth.wallet, chain.id()).await } -pub(super) async fn send_tip20_transaction( +pub(crate) async fn send_tip20_transaction( to: NameOrAddress, sig: &'static str, args: Vec, @@ -208,6 +208,7 @@ pub(super) async fn send_tip20_transaction( let mut tx_opts = tx_params.into_transaction_opts(); let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash; let sponsor_url = tx_opts.tempo.sponsor_url.clone(); + let sponsor_fee_payer = tx_opts.tempo.sponsor; let expires_at = tx_opts.tempo.resolve_expires(); let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() { None @@ -245,7 +246,7 @@ pub(super) async fn send_tip20_transaction( let chain = builder.chain(); if print_sponsor_hash { - let (tx, from) = if let Some(ref ak) = access_key { + let (mut tx, from) = if let Some(ref ak) = access_key { let (tx, _) = builder.build_with_access_key(ak.wallet_address, ak).await?; (tx, ak.wallet_address) } else { @@ -256,6 +257,15 @@ pub(super) async fn send_tip20_transaction( let (tx, _) = builder.build(signer).await?; (tx, from) }; + if let Some(fee_payer) = sponsor_fee_payer { + resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + Some(fee_payer), + ) + .await?; + } let hash = tx .compute_sponsor_hash(from) .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))?; @@ -275,14 +285,24 @@ pub(super) async fn send_tip20_transaction( tx.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER); } if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + ) + .await?; sponsor.attach_and_print::(&mut tx, browser.address()).await?; + } else { + let fee_token = resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + Some(browser.address()), + ) + .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?; } - maybe_print_resolved_fee_token( - (!config.eth_rpc_curl).then_some(&provider), - Some(chain), - tx.fee_token(), - ) - .await?; let tx_hash = browser.send_transaction_via_browser(tx).await?; CastTxSender::new(&provider) .print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout) @@ -294,6 +314,13 @@ pub(super) async fn send_tip20_transaction( let (mut tx, _) = builder.build_with_access_key(ak.wallet_address, &ak).await?; maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?; if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + ) + .await?; sponsor.attach_and_print::(&mut tx, ak.wallet_address).await?; } cast_send_with_access_key( @@ -301,11 +328,12 @@ pub(super) async fn send_tip20_transaction( tx, signer, &ak, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, send_tx.cast_async, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; } else if let Some(sponsor_url) = sponsor_url { @@ -333,12 +361,13 @@ pub(super) async fn send_tip20_transaction( cast_send( provider, tx, - Some(chain), + None, + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + false, ) .await?; } else { @@ -352,6 +381,13 @@ pub(super) async fn send_tip20_transaction( let (mut tx, _) = builder.build(&signer).await?; maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?; if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + (!config.eth_rpc_curl).then_some(&provider), + Some(chain), + &mut tx, + ) + .await?; sponsor.attach_and_print::(&mut tx, from).await?; } @@ -362,12 +398,13 @@ pub(super) async fn send_tip20_transaction( cast_send( provider, tx, - Some(chain), + tempo_sponsor.is_none().then_some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, timeout, - !config.eth_rpc_curl, + tempo_sponsor.is_none() && !config.eth_rpc_curl, ) .await?; } diff --git a/crates/cast/src/cmd/vaddr/create.rs b/crates/cast/src/cmd/vaddr/create.rs index fb077efa86c75..58ba37aaf02c8 100644 --- a/crates/cast/src/cmd/vaddr/create.rs +++ b/crates/cast/src/cmd/vaddr/create.rs @@ -21,7 +21,7 @@ use foundry_common::{ fmt::{UIfmt, UIfmtReceiptExt}, provider::ProviderBuilder, shell, - tempo::maybe_print_resolved_fee_token, + tempo::{maybe_print_fee_token, resolve_and_set_fee_token}, }; use rand::{RngCore, SeedableRng, rngs::StdRng}; use serde_json::json; @@ -189,12 +189,14 @@ async fn register( tempo::fill_access_key_transaction(&provider, &mut tx, access_key, chain).await?; if shell::is_json() { // JSON mode bypasses `cast_send_with_access_key`, so report the selection here. - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( (!config.eth_rpc_curl).then_some(&provider), Some(chain), - tx.fee_token(), + &mut tx, + Some(access_key.wallet_address), ) .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?; let raw_tx = tx .sign_with_access_key( &provider, @@ -221,6 +223,7 @@ async fn register( &signer, access_key, Some(chain), + None, send_tx.cast_async, send_tx.confirmations, timeout, @@ -232,12 +235,14 @@ async fn register( let provider = build_provider_with_signer::(&send_tx, signer)?; if shell::is_json() { // JSON mode bypasses `cast_send`, so report the selection here. - maybe_print_resolved_fee_token( + let fee_token = resolve_and_set_fee_token( (!config.eth_rpc_curl).then_some(&provider), Some(chain), - tx.fee_token(), + &mut tx, + Some(sender), ) .await?; + maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?; let cast = CastTxSender::new(&provider); if send_tx.sync { cast.send_sync(tx).await.map(|(tx_hash, _)| tx_hash) @@ -259,6 +264,7 @@ async fn register( provider, tx, Some(chain), + None, send_tx.cast_async, send_tx.sync, send_tx.confirmations, diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index 1ada81ab5d052..f5f14292973c2 100644 --- a/crates/cast/src/opts.rs +++ b/crates/cast/src/opts.rs @@ -18,6 +18,7 @@ use crate::cmd::{ keychain::{KeyAuthorizationSubcommand, KeychainSubcommand}, logs::LogsArgs, mktx::MakeTxArgs, + receive_policy::ReceivePolicySubcommand, rpc::RpcArgs, run::RunArgs, send::SendTxArgs, @@ -1257,6 +1258,13 @@ pub enum CastSubcommand { command: Tip20Subcommand, }, + /// Account-level receive policy operations (Tempo). + #[command(name = "receive-policy")] + ReceivePolicy { + #[command(subcommand)] + command: ReceivePolicySubcommand, + }, + /// Tempo keychain (access key) management. #[command(visible_alias = "kc")] Keychain { diff --git a/crates/cast/tests/cli/keychain.rs b/crates/cast/tests/cli/keychain.rs index 770963dbd1a6d..4ff3c0021d68c 100644 --- a/crates/cast/tests/cli/keychain.rs +++ b/crates/cast/tests/cli/keychain.rs @@ -213,7 +213,7 @@ casttest!(keychain_authorize_sponsor_hash_json_is_object, async |_prj, cmd| { assert_eq!(hash.len(), 66, "sponsor_hash should be 32-byte hex (66 chars), got: {hash}"); }); -// TODO: remove this check once browser supports T5 KeyAuthorization fields +// TODO: remove this check once browser supports T5/T6 KeyAuthorization fields casttest!(key_authorization_sign_rejects_browser_witness_before_browser_run, |_prj, cmd| { let stderr = cmd .args([ @@ -232,8 +232,9 @@ casttest!(key_authorization_sign_rejects_browser_witness_before_browser_run, |_p .stderr_lossy(); assert!( - stderr - .contains("browser key authorization signing does not support T5 fields yet: witness"), + stderr.contains( + "browser key authorization signing does not support T5/T6 fields yet: witness, admin, account" + ), "unexpected stderr:\n{stderr}" ); assert!( @@ -242,6 +243,356 @@ casttest!(key_authorization_sign_rejects_browser_witness_before_browser_run, |_p ); }); +// Offline: signing an admin key authorization round-trips `is_admin`/`account` into the JSON. +casttest!(key_authorization_sign_admin_emits_admin_json, |_prj, cmd| { + // `sign --admin` derives the bound account from the signer (it has no auth-level --account), + // so signing with PK1 binds the authorization to ADDR1. + let output = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--private-key", + accounts::PK1, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast key-authorization sign --admin --json should emit valid JSON"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); + // The bound account equals the signer (ADDR1), not the authorized key (ADDR2). + assert_eq!( + parsed["account"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); + assert!( + parsed["signed_key_authorization"].as_str().is_some_and(|s| s.starts_with("0x")), + "got: {output}" + ); +}); + +// Offline: `--admin` without `--account` is rejected before any signing happens. +casttest!(key_authorization_encode_admin_requires_account, |_prj, cmd| { + let stderr = cmd + .args(["key-authorization", "encode", accounts::ADDR2, "--chain-id", "31337", "--admin"]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("--admin requires --account"), "unexpected stderr:\n{stderr}"); +}); + +// Offline: an admin key authorization cannot carry an expiry. +casttest!(key_authorization_encode_admin_rejects_expiry, |_prj, cmd| { + let stderr = cmd + .args([ + "key-authorization", + "encode", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--account", + accounts::ADDR1, + "--expiry", + "1782647677", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("--expiry"), "unexpected stderr:\n{stderr}"); +}); + +// Offline: `key-authorization inspect` decodes a signed admin authorization and exposes T6 fields. +casttest!(key_authorization_inspect_signed_admin_json, |_prj, cmd| { + let signed = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--private-key", + accounts::PK1, // root account ADDR1; the bound account is derived from the signer + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let signed = signed.trim().to_string(); + + let output = cmd + .cast_fuse() + .args(["key-authorization", "inspect", &signed, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast key-authorization inspect --json should emit valid JSON"); + assert_eq!(parsed["signed"], serde_json::Value::Bool(true), "got: {output}"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); + assert_eq!( + parsed["account"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); + assert_eq!( + parsed["signer"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); +}); + +// Offline: inspecting with a mismatched `--account` rejects a replayed admin authorization. +casttest!(key_authorization_inspect_account_mismatch_rejected, |_prj, cmd| { + let signed = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--private-key", + accounts::PK1, // bound to ADDR1 + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let signed = signed.trim().to_string(); + + let stderr = cmd + .cast_fuse() + .args(["key-authorization", "inspect", &signed, "--account", accounts::ADDR2]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("is bound to account"), "unexpected stderr:\n{stderr}"); +}); + +// On-chain (T6): authorize an admin key, then confirm it via `keychain is-admin --json`. +casttest!(keychain_authorize_admin_then_is_admin, async |_prj, cmd| { + use tempo_chainspec::hardfork::TempoHardfork; + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, // admin key to authorize + "--admin", + "--private-key", + accounts::PK1, // root account ADDR1 + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let output = cmd + .cast_fuse() + .args([ + "keychain", + "is-admin", + accounts::ADDR1, // root account + accounts::ADDR2, // admin key + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast keychain is-admin --json should emit valid JSON"); + assert!(parsed.is_object(), "expected JSON object, got: {output}"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); +}); + +// On-chain (T6): a keychain signature from an authorized admin key passes `verify-admin`. +casttest!(keychain_verify_admin_accepts_admin_signature, async |_prj, cmd| { + use alloy_primitives::{Address, B256, hex}; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_chainspec::hardfork::TempoHardfork; + use tempo_primitives::transaction::{KeychainSignature, PrimitiveSignature, TempoSignature}; + + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + // Authorize ADDR2 as an admin key for ADDR1 (PK1). + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--admin", + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + // Build a keychain signature over an arbitrary hash, signed by the admin key (PK2) for ADDR1. + let account: Address = accounts::ADDR1.parse().unwrap(); + let hash = B256::repeat_byte(0x42); + let key: PrivateKeySigner = accounts::PK2.parse().unwrap(); + let signing_hash = KeychainSignature::signing_hash(hash, account); + let inner = key.sign_hash_sync(&signing_hash).unwrap(); + let signature = TempoSignature::Keychain(KeychainSignature::new( + account, + PrimitiveSignature::Secp256k1(inner), + )) + .to_bytes(); + let signature_hex = hex::encode_prefixed(signature); + + let output = cmd + .cast_fuse() + .args([ + "keychain", + "verify-admin", + accounts::ADDR1, + &hash.to_string(), + &signature_hex, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast keychain verify-admin --json should emit valid JSON"); + assert_eq!(parsed["valid"], serde_json::Value::Bool(true), "got: {output}"); +}); + +// On-chain (T6): `keychain authorize --admin` rejects spending limits before submitting. +casttest!(keychain_authorize_admin_rejects_limits, async |_prj, cmd| { + use tempo_chainspec::hardfork::TempoHardfork; + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + let stderr = cmd + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--admin", + "--enforce-limits", + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("spending limits"), "unexpected stderr:\n{stderr}"); +}); + +// An access-key signer is rejected for admin-gated keychain mutators even when it is an active +// admin key, because submitting the mutator as access-key-signed calldata reverts on-chain with +// `UnauthorizedCaller()` on the pinned Tempo build. +casttest!(keychain_access_key_cannot_submit_admin_mutator, async |_prj, cmd| { + use tempo_chainspec::hardfork::TempoHardfork; + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + // Root ADDR1 (PK1) authorizes ADDR2 (PK2) as an admin key. + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--admin", + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + // ADDR2 is an active admin key, but the access-key mutator is still rejected locally. + let stderr = cmd + .cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR3, + "--tempo.access-key", + accounts::PK2, + "--tempo.root-account", + accounts::ADDR1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("currently reverts on-chain with UnauthorizedCaller()"), + "unexpected stderr:\n{stderr}" + ); +}); + +// Offline (T6): an admin access key signing a child authorization binds the authorization to the +// root account it manages, not to the signing admin key. This covers the delegated admin-signing +// path in `run_key_auth_sign`. +casttest!(key_authorization_sign_admin_access_key_binds_root_account, |_prj, cmd| { + let output = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR3, // the child key being authorized + "--chain-id", + "31337", + "--admin", + "--tempo.access-key", + accounts::PK2, // admin key ADDR2 signs + "--tempo.root-account", + accounts::ADDR1, // on behalf of root ADDR1 + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast key-authorization sign --json should emit valid JSON"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); + // The signer is the admin key (ADDR2)... + assert_eq!( + parsed["signer"].as_str().map(str::to_lowercase), + Some(accounts::ADDR2.to_lowercase()), + "got: {output}" + ); + // ...but the bound account is the root account being modified (ADDR1), not the signer. + assert_eq!( + parsed["account"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); +}); + casttest!(keychain_doctor_json_keeps_report_schema_version, async |_prj, cmd| { let output = cmd .args([ diff --git a/crates/cheatcodes/src/test/expect.rs b/crates/cheatcodes/src/test/expect.rs index 55709c268e16c..318ea8449a361 100644 --- a/crates/cheatcodes/src/test/expect.rs +++ b/crates/cheatcodes/src/test/expect.rs @@ -1172,6 +1172,17 @@ pub(crate) fn get_emit_mismatch_message( // 1. Different number of topics if actual.topics().len() != expected.topics().len() { + let expected_name = expected_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log"); + let actual_name = actual_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log"); + let expected_topics = checked_topic_count(expected, is_anonymous); + let actual_topics = checked_topic_count(actual, is_anonymous); + + if expected_name == actual_name { + return format!( + "{actual_name} indexed topic count mismatch: expected {expected_topics}, got {actual_topics}" + ); + } + return name_mismatched_logs(expected_decoded, actual_decoded); } @@ -1314,6 +1325,10 @@ fn name_mismatched_logs( format!("{actual_name} != expected {expected_name}") } +fn checked_topic_count(log: &RawLog, is_anonymous: bool) -> usize { + if is_anonymous { log.topics().len() } else { log.topics().len().saturating_sub(1) } +} + fn expect_safe_memory( state: &mut Cheatcodes, start: u64, diff --git a/crates/chisel/src/session.rs b/crates/chisel/src/session.rs index 87eb1fb140f10..c3a619c6ff7f0 100644 --- a/crates/chisel/src/session.rs +++ b/crates/chisel/src/session.rs @@ -111,26 +111,8 @@ impl ChiselSession { /// Optionally, returns a tuple containing the next cached session's id and file name. pub fn next_cached_session() -> Result<(String, String)> { let cache_dir = Self::cache_dir()?; - let mut entries = std::fs::read_dir(&cache_dir)?; - - // If there are no existing cached sessions, just create the first one: "chisel-0.json" - let mut latest = if let Some(e) = entries.next() { - e? - } else { - return Ok((String::from("0"), format!("{cache_dir}chisel-0.json"))); - }; - - let mut session_num = 1; - // Get the latest cached session - for entry in entries { - let entry = entry?; - if entry.metadata()?.modified()? >= latest.metadata()?.modified()? { - latest = entry; - } - - // Increase session_num counter rather than cloning the iterator and using `.count` - session_num += 1; - } + let entries = std::fs::read_dir(&cache_dir)?; + let session_num = entries.filter(Result::is_ok).count(); Ok((format!("{session_num}"), format!("{cache_dir}chisel-{session_num}.json"))) } diff --git a/crates/cli/src/opts/tempo.rs b/crates/cli/src/opts/tempo.rs index b2fde07e456ad..fff691a2e6cad 100644 --- a/crates/cli/src/opts/tempo.rs +++ b/crates/cli/src/opts/tempo.rs @@ -138,7 +138,7 @@ pub struct TempoOpts { /// knows what hash to sign. The transaction is not sent. #[arg( long = "tempo.print-sponsor-hash", - conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "sponsor_url"] + conflicts_with_all = &["sponsor_signer", "sponsor_sig", "sponsor_url"] )] pub print_sponsor_hash: bool, @@ -453,13 +453,29 @@ mod tests { } #[test] - fn print_sponsor_hash_conflicts_with_sponsor_submission() { + fn print_sponsor_hash_allows_sponsor_address() { + let opts = TempoOpts::try_parse_from([ + "", + "--tempo.print-sponsor-hash", + "--tempo.sponsor", + "0x1111111111111111111111111111111111111111", + ]) + .unwrap(); + + assert!(opts.print_sponsor_hash); + assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111"))); + } + + #[test] + fn print_sponsor_hash_conflicts_with_sponsor_signature() { assert!( TempoOpts::try_parse_from([ "", "--tempo.print-sponsor-hash", "--tempo.sponsor", "0x1111111111111111111111111111111111111111", + "--tempo.sponsor-signature", + "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b", ]) .is_err() ); diff --git a/crates/common/src/tempo/auth.rs b/crates/common/src/tempo/auth.rs index d79306cfb74f2..c2c70855f48a1 100644 --- a/crates/common/src/tempo/auth.rs +++ b/crates/common/src/tempo/auth.rs @@ -198,6 +198,20 @@ pub async fn ensure_access_key(cfg: EnsureAccessKeyConfig) -> Result, + } + #[derive(Clone)] struct MockState { wallet: Arc>>, @@ -360,6 +383,8 @@ mod tests { key_id: Arc>>, /// Chain ID the mock `/poll` returns in `keyAuthorization`. poll_chain_id: u64, + /// Shape of the returned authorization, for exercising rejection paths. + shape: MockAuthShape, } async fn create_code_handler( @@ -386,11 +411,25 @@ mod tests { /// Build the RLP-hex `SignedKeyAuthorization` blob the live server returns /// in the `key_authorization` field. - fn signed_key_auth_hex(chain_id: u64, key_id: Address, expiry: u64) -> String { + fn signed_key_auth_hex( + chain_id: u64, + key_id: Address, + expiry: u64, + shape: MockAuthShape, + ) -> String { use alloy_rlp::Encodable; use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature}; - let auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id) - .with_expiry(expiry); + let mut auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id); + if shape.admin { + // An admin authorization carries no expiry; bind it to its account (or zero, which the + // `is_admin` check rejects before the account is even inspected). + auth = auth.into_admin(shape.account.unwrap_or(Address::ZERO)); + } else { + auth = auth.with_expiry(expiry); + if let Some(account) = shape.account { + auth = auth.with_account(account); + } + } let sig: PrimitiveSignature = serde_json::from_value(serde_json::json!({ "type": "secp256k1", "r": "0x0", "s": "0x0", "yParity": 0 })) @@ -407,12 +446,21 @@ mod tests { Json(serde_json::json!({ "status": "authorized", "account_address": wallet, - "key_authorization": signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999), + "key_authorization": + signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999, state.shape), })) } /// Spawn a mock wallet.tempo server whose `/poll` echoes `poll_chain_id`. async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) { + spawn_mock_wallet_with(poll_chain_id, MockAuthShape::default()).await + } + + /// Spawn a mock wallet.tempo server with a custom authorization shape. + async fn spawn_mock_wallet_with( + poll_chain_id: u64, + shape: MockAuthShape, + ) -> (String, tokio::task::JoinHandle<()>) { let app = Router::new() .route("/code", post(create_code_handler)) .route("/poll/{code}", post(poll_handler)) @@ -420,6 +468,7 @@ mod tests { wallet: Arc::default(), key_id: Arc::default(), poll_chain_id, + shape, }); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -491,4 +540,58 @@ mod tests { server.abort(); unsafe { std::env::remove_var(TEMPO_HOME_ENV) }; } + + #[tokio::test(flavor = "multi_thread")] + async fn ensure_access_key_rejects_admin_authorization() { + // An admin key authorization from the wallet must be rejected before keys.toml is written. + let _g = test_env_mutex().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) }; + + // Bind the admin auth to the mock wallet account (0x..42) so it is rejected purely for + // being an admin key, not for an account mismatch. + let account: Address = "0x0000000000000000000000000000000000000042".parse().unwrap(); + let shape = MockAuthShape { admin: true, account: Some(account) }; + let (service_url, server) = spawn_mock_wallet_with(4217, shape).await; + + let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err(); + assert!( + err.to_string().contains("admin key authorization"), + "expected admin-key rejection, got: {err}" + ); + assert!( + read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()), + "an admin authorization must not be persisted to keys.toml" + ); + + server.abort(); + unsafe { std::env::remove_var(TEMPO_HOME_ENV) }; + } + + #[tokio::test(flavor = "multi_thread")] + async fn ensure_access_key_rejects_cross_account_binding() { + // An authorization bound to an account other than the authorizing one must be rejected + // before keys.toml is written. + let _g = test_env_mutex().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) }; + + // The mock authorizes account 0x..42 but binds the authorization to 0x..dead. + let other: Address = "0x000000000000000000000000000000000000dead".parse().unwrap(); + let shape = MockAuthShape { admin: false, account: Some(other) }; + let (service_url, server) = spawn_mock_wallet_with(4217, shape).await; + + let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err(); + assert!( + err.to_string().contains("wallet authorized account"), + "expected cross-account rejection, got: {err}" + ); + assert!( + read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()), + "a cross-account authorization must not be persisted to keys.toml" + ); + + server.abort(); + unsafe { std::env::remove_var(TEMPO_HOME_ENV) }; + } } diff --git a/crates/common/src/tempo/mod.rs b/crates/common/src/tempo/mod.rs index abf1884b15471..d8f4a95023b75 100644 --- a/crates/common/src/tempo/mod.rs +++ b/crates/common/src/tempo/mod.rs @@ -13,7 +13,7 @@ use eyre::{Context, Result}; use foundry_wallets::{RawWalletOpts, WalletOpts, WalletSigner}; use std::sync::Arc; pub use tempo_alloy::contracts::precompiles::PATH_USD_ADDRESS; -use tempo_alloy::contracts::precompiles::{DEFAULT_FEE_TOKEN, ITIP20}; +use tempo_alloy::contracts::precompiles::{IFeeManager, ITIP20, TIP_FEE_MANAGER_ADDRESS}; mod keystore; mod registry; @@ -67,18 +67,66 @@ pub const ALPHA_USD_ADDRESS: Address = address!("0x20C00000000000000000000000000 pub const BETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000002"); pub const THETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000003"); -/// Resolves an explicit Tempo fee token or the canonical default for a known Tempo network. +/// Resolves and applies the Tempo fee token selected by the network. /// -/// TODO: fee token resolution is incomplete, must use FeeTokenManager. -pub fn resolve_fee_token( +/// This must happen before computing a sponsor digest, because Tempo sponsor signatures commit to +/// the fee token. +pub async fn resolve_and_set_fee_token( + provider: Option<&dyn Provider>, chain: Option, - explicit_fee_token: Option
, -) -> Option
{ - explicit_fee_token.or_else(|| chain.is_some_and(Chain::is_tempo).then_some(DEFAULT_FEE_TOKEN)) + tx: &mut N::TransactionRequest, + fee_payer: Option
, +) -> Result> +where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, +{ + if let Some(fee_token) = tx.fee_token() { + return Ok(Some(fee_token)); + } + if !chain.is_some_and(Chain::is_tempo) { + return Ok(None); + } + let Some(provider) = provider else { + return Ok(None); + }; + + let fee_payer = fee_payer.or_else(|| tx.from()); + let fee_token = if let Some(fee_payer) = fee_payer { + stored_user_fee_token(provider, fee_payer).await? + } else { + None + }; + let Some(fee_token) = fee_token else { + return Ok(None); + }; + tx.set_fee_token(fee_token); + Ok(Some(fee_token)) +} + +async fn stored_user_fee_token( + provider: &dyn Provider, + fee_payer: Address, +) -> Result> +where + N: Network, + N::TransactionRequest: Default + NetworkTransactionBuilder, +{ + let call = IFeeManager::userTokensCall { user: fee_payer }; + let tx = N::TransactionRequest::default() + .with_to(TIP_FEE_MANAGER_ADDRESS) + .with_input(call.abi_encode()); + let output = provider + .call(tx) + .await + .wrap_err_with(|| format!("failed to resolve Tempo fee token for {fee_payer}"))?; + let fee_token = IFeeManager::userTokensCall::abi_decode_returns(&output) + .wrap_err("failed to decode Tempo fee token lookup")?; + Ok((!fee_token.is_zero()).then_some(fee_token)) } /// Returns the known symbol for a Tempo fee token without making an RPC call. -pub const fn known_fee_token_symbol(fee_token: Address) -> Option<&'static str> { +const fn known_fee_token_symbol(fee_token: Address) -> Option<&'static str> { match fee_token { PATH_USD_ADDRESS => Some("PathUSD"), ALPHA_USD_ADDRESS => Some("AlphaUSD"), @@ -106,7 +154,7 @@ where (!symbol.is_empty()).then_some(symbol) } -/// Prints the selected Tempo fee token when one is set. +/// Prints the fee token selected for display. /// /// Unknown symbols are resolved on-chain only when a provider is supplied, because some provider /// modes such as `--curl` must preserve the first RPC request for the user's intended action. @@ -135,21 +183,6 @@ where Ok(()) } -/// Prints the fee token selected for display, resolving the chain default and unknown symbols -/// without mutating a transaction request. -pub async fn maybe_print_resolved_fee_token( - provider: Option<&P>, - chain: Option, - fee_token: Option
, -) -> Result<()> -where - N: Network, - N::TransactionRequest: Default + NetworkTransactionBuilder, - P: Provider, -{ - maybe_print_fee_token(provider, resolve_fee_token(chain, fee_token)).await -} - /// Gas sponsor configuration for Tempo fee-payer signatures. #[derive(Clone, Debug)] pub struct TempoSponsor { @@ -171,6 +204,23 @@ impl TempoSponsor { self.sponsor } + /// Resolves the fee token paid by this sponsor and applies it to the transaction request. + /// + /// This must happen before computing a sponsor digest, because Tempo sponsor signatures commit + /// to the fee token. + pub async fn resolve_and_set_fee_token( + &self, + provider: Option<&dyn Provider>, + chain: Option, + tx: &mut N::TransactionRequest, + ) -> Result> + where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, + { + resolve_and_set_fee_token(provider, chain, tx, Some(self.sponsor)).await + } + pub async fn attach_and_print( &self, tx: &mut N::TransactionRequest, diff --git a/crates/common/src/tempo/session.rs b/crates/common/src/tempo/session.rs index 309524e4d8a29..51ab9431c8a4a 100644 --- a/crates/common/src/tempo/session.rs +++ b/crates/common/src/tempo/session.rs @@ -364,6 +364,23 @@ pub(crate) fn validate_signed_session_authorization( auth.key_type, expected_key_type ); + // A session uses a limited access key; T6 admin keys must never be used as a session key. + ensure!( + !auth.is_admin(), + "session {} key_authorization is an admin key, expected a limited access key", + session.session_id + ); + // A T6 account-bound authorization must target this session's root account (no cross-account + // replay). + if let Some(account) = auth.account { + ensure!( + account == session.root_account, + "session {} key_authorization is bound to account {}, expected {}", + session.session_id, + account, + session.root_account + ); + } // `session_id` is local metadata; the signed binding lives in the authorization witness. ensure!( auth.witness == Some(session.session_id), @@ -1120,6 +1137,62 @@ mod tests { }); } + #[test] + fn resolve_rejects_admin_key_authorization() { + with_tempo_home(|| { + let session_id = B256::from([0x17; 32]); + let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active); + // A session must use a limited access key, never a T6 admin key. + entry.key.as_mut().unwrap().key_authorization = + Some(signed_key_authorization_hex_with(&entry, |mut auth| { + auth.is_admin = true; + auth + })); + upsert_session_entry(entry).unwrap(); + + let error = resolve_live_session_signer(session_id, 100).unwrap_err(); + + assert!(error.to_string().contains("admin key"), "got: {error}"); + }); + } + + #[test] + fn resolve_rejects_account_bound_to_other_account() { + with_tempo_home(|| { + let session_id = B256::from([0x18; 32]); + let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active); + // An account-bound authorization minted for another account must not be replayable. + entry.key.as_mut().unwrap().key_authorization = + Some(signed_key_authorization_hex_with(&entry, |auth| { + auth.with_account( + Address::from_str("0x000000000000000000000000000000000000dead").unwrap(), + ) + })); + upsert_session_entry(entry).unwrap(); + + let error = resolve_live_session_signer(session_id, 100).unwrap_err(); + + assert!(error.to_string().contains("bound to account"), "got: {error}"); + }); + } + + #[test] + fn resolve_accepts_account_bound_to_root() { + with_tempo_home(|| { + let session_id = B256::from([0x19; 32]); + let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active); + let root_account = entry.root_account; + // An account binding that targets the session root is valid (backward compatible). + entry.key.as_mut().unwrap().key_authorization = + Some(signed_key_authorization_hex_with(&entry, |auth| { + auth.with_account(root_account) + })); + upsert_session_entry(entry).unwrap(); + + assert!(resolve_live_session_signer(session_id, 100).is_ok()); + }); + } + #[test] fn resolve_live_session_signer_rejects_authorization_with_wider_session_limit() { with_tempo_home(|| { diff --git a/crates/common/src/tempo/tests.rs b/crates/common/src/tempo/tests.rs index 8843d46a01cdd..57b33f852bc08 100644 --- a/crates/common/src/tempo/tests.rs +++ b/crates/common/src/tempo/tests.rs @@ -3,15 +3,18 @@ use eyre::WrapErr; use foundry_evm_hardforks::TempoHardfork; use serde::Deserialize; use std::env; -use tempo_alloy::contracts::precompiles::DEFAULT_FEE_TOKEN; use alloy_chains::{Chain, NamedChain}; +use alloy_network::TransactionBuilder; use alloy_primitives::{Address, address}; -use tempo_alloy::TempoNetwork; +use alloy_provider::mock::Asserter; +use alloy_rpc_types::TransactionRequest; +use alloy_sol_types::SolValue; +use tempo_alloy::{TempoNetwork, rpc::TempoTransactionRequest}; use super::{ - ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, PATH_USD_ADDRESS, THETA_USD_ADDRESS, - known_fee_token_symbol, resolve_fee_token, resolve_fee_token_symbol, + ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, PATH_USD_ADDRESS, THETA_USD_ADDRESS, TempoSponsor, + known_fee_token_symbol, resolve_and_set_fee_token, resolve_fee_token_symbol, }; #[derive(Debug, Deserialize)] @@ -64,36 +67,183 @@ async fn test_fork_schedule_parses_configured_rpcs() -> eyre::Result<()> { Ok(()) } -#[test] -fn resolves_canonical_fee_token_for_tempo_chains() { - for chain in [ - NamedChain::Tempo, - NamedChain::TempoModerato, - NamedChain::TempoTestnet, - NamedChain::TempoDevnet, - ] { - assert_eq!(resolve_fee_token(Some(chain.into()), None), Some(DEFAULT_FEE_TOKEN)); - } +#[tokio::test] +async fn explicit_fee_token_overrides_stored_user_token_when_applied() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter); + let explicit = Address::repeat_byte(0x42); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(fee_payer), + fee_token: Some(explicit), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::( + Some(&provider), + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + Some(fee_payer), + ) + .await?, + Some(explicit) + ); + assert_eq!(tx.fee_token, Some(explicit)); + Ok(()) } -#[test] -fn leaves_non_tempo_chains_without_a_default() { - assert_eq!(resolve_fee_token(Some(NamedChain::Mainnet.into()), None), None); +#[tokio::test] +async fn default_fee_token_resolution_leaves_transaction_fee_token_unset() -> eyre::Result<()> { + let explicit = Address::repeat_byte(0x42); + let mut tx = TempoTransactionRequest { fee_token: Some(explicit), ..Default::default() }; + + let resolved = resolve_and_set_fee_token::( + None, + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + None, + ) + .await?; + assert_eq!(resolved, Some(explicit)); + assert_eq!(tx.fee_token, Some(explicit)); + + let mut tx = TempoTransactionRequest::default(); + let resolved = resolve_and_set_fee_token::( + None, + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + None, + ) + .await?; + assert_eq!(resolved, None); + assert_eq!(tx.fee_token, None); + + let mut tx = TempoTransactionRequest::default(); + let resolved = resolve_and_set_fee_token::( + None, + Some(Chain::from_named(NamedChain::Mainnet)), + &mut tx, + None, + ) + .await?; + assert_eq!(resolved, None); + assert_eq!(tx.fee_token, None); + Ok(()) } -#[test] -fn leaves_unknown_chain_without_a_default() { - assert_eq!(resolve_fee_token(None, None), None); +#[tokio::test] +async fn send_fee_token_resolution_can_skip_lookup_for_curl_mode() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter.clone()); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest::default(); + + asserter.push_success(&BETA_USD_ADDRESS.abi_encode()); + assert_eq!( + resolve_and_set_fee_token::( + Some(&provider), + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + Some(fee_payer), + ) + .await?, + Some(BETA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(BETA_USD_ADDRESS)); + + let mut tx = TempoTransactionRequest::default(); + assert_eq!( + resolve_and_set_fee_token::( + None, + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + Some(fee_payer), + ) + .await?, + None + ); + assert_eq!(tx.fee_token, None); + + Ok(()) } -#[test] -fn explicit_fee_token_overrides_chain_default() { +#[tokio::test] +async fn unset_user_token_does_not_stamp_default_fee_token() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter.clone()); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest::default(); + + asserter.push_success(&Address::ZERO.abi_encode()); + + assert_eq!( + resolve_and_set_fee_token::( + Some(&provider), + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + Some(fee_payer), + ) + .await?, + None + ); + assert_eq!(tx.fee_token, None); + + Ok(()) +} + +#[tokio::test] +async fn sponsor_fee_token_resolution_uses_sponsor_address() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter.clone()); + let sponsor = TempoSponsor::new(Address::repeat_byte(0x22), None, None); + let sender = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(sender), + ..Default::default() + }; + + asserter.push_success(&BETA_USD_ADDRESS.abi_encode()); + + assert_eq!( + sponsor + .resolve_and_set_fee_token::( + Some(&provider), + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + ) + .await?, + Some(BETA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(BETA_USD_ADDRESS)); + Ok(()) +} + +#[tokio::test] +async fn sponsor_fee_token_resolution_preserves_explicit_token() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter); let explicit = Address::repeat_byte(0x42); + let sponsor = TempoSponsor::new(Address::repeat_byte(0x22), None, None); + let mut tx = TempoTransactionRequest { fee_token: Some(explicit), ..Default::default() }; + assert_eq!( - resolve_fee_token(Some(Chain::from_named(NamedChain::Tempo)), Some(explicit)), + sponsor + .resolve_and_set_fee_token::( + Some(&provider), + Some(Chain::from_named(NamedChain::Tempo)), + &mut tx, + ) + .await?, Some(explicit) ); - assert_eq!(resolve_fee_token(None, Some(explicit)), Some(explicit)); + assert_eq!(tx.fee_token, Some(explicit)); + Ok(()) } #[test] diff --git a/crates/debugger/src/tui/context.rs b/crates/debugger/src/tui/context.rs index 9a8222a2c3043..786bd54defdfa 100644 --- a/crates/debugger/src/tui/context.rs +++ b/crates/debugger/src/tui/context.rs @@ -68,6 +68,10 @@ pub(crate) struct TUIContext<'a> { pub(crate) key_buffer: String, /// Current goto program counter prompt contents, if the prompt is active. pub(crate) pc_input: Option, + /// Current opcode search prompt contents, if the prompt is active. + pub(crate) opcode_search_input: Option, + /// Last opcode search term, used by repeat-search shortcuts. + pub(crate) last_opcode_search: Option, /// Last status or error message to show in the footer. pub(crate) status: Option, /// Current step in the debug steps. @@ -91,6 +95,8 @@ impl<'a> TUIContext<'a> { key_buffer: String::with_capacity(64), pc_input: None, + opcode_search_input: None, + last_opcode_search: None, status: None, current_step: 0, draw_memory: DrawMemory::default(), @@ -178,6 +184,11 @@ impl TUIContext<'_> { } fn handle_key_event(&mut self, event: KeyEvent) -> ControlFlow { + if self.opcode_search_input.is_some() { + self.handle_opcode_search_input_key_event(event); + return ControlFlow::Continue(()); + } + if self.pc_input.is_some() { self.handle_pc_input_key_event(event); return ControlFlow::Continue(()); @@ -314,6 +325,23 @@ impl TUIContext<'_> { self.pc_input = Some(String::new()); } + // Search opcodes in the current call + KeyCode::Char('/') => { + self.key_buffer.clear(); + self.status = None; + self.opcode_search_input = Some(String::new()); + } + + // Repeat opcode search forward + KeyCode::Char('n') => self.repeat(|this| { + this.repeat_opcode_search(SearchDirection::Forward); + }), + + // Repeat opcode search backward + KeyCode::Char('N') => self.repeat(|this| { + this.repeat_opcode_search(SearchDirection::Backward); + }), + // Toggle help notice KeyCode::Char('h') => self.show_shortcuts = !self.show_shortcuts, @@ -359,6 +387,65 @@ impl TUIContext<'_> { } } + fn handle_opcode_search_input_key_event(&mut self, event: KeyEvent) { + match event.code { + KeyCode::Esc => { + self.opcode_search_input = None; + } + KeyCode::Enter => { + let input = self.opcode_search_input.take().unwrap_or_default(); + self.search_opcode_from_input(&input); + } + KeyCode::Backspace => { + if let Some(input) = &mut self.opcode_search_input { + input.pop(); + } + } + KeyCode::Char(c) if !event.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(input) = &mut self.opcode_search_input { + input.push(c); + } + } + _ => {} + } + } + + fn search_opcode_from_input(&mut self, input: &str) { + let query = input.trim(); + if query.is_empty() { + self.set_error("Enter an opcode search term".to_string()); + return; + } + + self.last_opcode_search = Some(query.to_string()); + self.search_opcode(query, SearchDirection::Forward); + } + + fn repeat_opcode_search(&mut self, direction: SearchDirection) { + let Some(query) = self.last_opcode_search.clone() else { + self.set_error("No previous opcode search".to_string()); + return; + }; + + self.search_opcode(&query, direction); + } + + fn search_opcode(&mut self, query: &str, direction: SearchDirection) { + let Some(step_index) = + find_opcode_match(&self.opcode_list, self.current_step, query, direction) + else { + self.set_error(format!("No opcode matching `{query}` in current call")); + return; + }; + + self.current_step = step_index; + self.scroll_memory_to_current_write(); + + let pc = self.current_step().pc; + let opcode = self.opcode_list.get(step_index).map(String::as_str).unwrap_or_default(); + self.set_info(format!("Found `{query}` at PC 0x{pc:x} ({pc}): {opcode}")); + } + fn goto_pc_from_input(&mut self, input: &str) { let candidates = match parse_pc_candidates(input) { Ok(candidates) => candidates, @@ -460,7 +547,7 @@ impl TUIContext<'_> { } fn handle_mouse_event(&mut self, event: MouseEvent) -> ControlFlow { - if self.pc_input.is_some() { + if self.pc_input.is_some() || self.opcode_search_input.is_some() { return ControlFlow::Continue(()); } @@ -560,6 +647,12 @@ const fn is_pc_input_char(c: char) -> bool { c.is_ascii_hexdigit() || matches!(c, 'x' | 'X' | ':') } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SearchDirection { + Forward, + Backward, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PcBase { Hex, @@ -720,6 +813,34 @@ fn pc_exists_outside_code_context(arena: &[DebugNode], current: &DebugNode, pc: }) } +fn find_opcode_match( + opcodes: &[String], + current_step: usize, + query: &str, + direction: SearchDirection, +) -> Option { + if opcodes.is_empty() { + return None; + } + + let needle = query.trim().to_ascii_lowercase(); + if needle.is_empty() { + return None; + } + + let current = current_step.min(opcodes.len() - 1); + let matches = |i: usize| opcodes[i].to_ascii_lowercase().contains(&needle); + + match direction { + SearchDirection::Forward => { + ((current + 1)..opcodes.len()).chain(0..=current).find(|&i| matches(i)) + } + SearchDirection::Backward => { + (0..current).rev().chain((current..opcodes.len()).rev()).find(|&i| matches(i)) + } + } +} + fn pretty_opcode(step: &CallTraceStep) -> String { if let Some(immediate) = step.immediate_bytes.as_ref().filter(|b| !b.is_empty()) { format!("{}(0x{})", step.op, hex::encode(immediate)) @@ -764,6 +885,13 @@ mod tests { step_with_stack(pc, OpCode::STOP, &[]) } + fn step_with_immediate(pc: usize, op: OpCode, immediate: &'static [u8]) -> CallTraceStep { + CallTraceStep { + immediate_bytes: Some(Bytes::from_static(immediate)), + ..step_with_stack(pc, op, &[]) + } + } + fn step_with_stack(pc: usize, op: OpCode, stack: &[usize]) -> CallTraceStep { CallTraceStep { pc, @@ -1041,6 +1169,154 @@ mod tests { assert_eq!(tui.status, None); } + #[test] + fn opcode_search_wraps_and_is_case_insensitive() { + let opcodes = + vec!["STOP".to_string(), "PUSH4(0x95d89b41)".to_string(), "MSTORE".to_string()]; + + assert_eq!(find_opcode_match(&opcodes, 0, "push4", SearchDirection::Forward), Some(1)); + assert_eq!(find_opcode_match(&opcodes, 0, "95D89B41", SearchDirection::Forward), Some(1)); + assert_eq!(find_opcode_match(&opcodes, 0, "mstore", SearchDirection::Backward), Some(2)); + assert_eq!(find_opcode_match(&opcodes, 0, "sload", SearchDirection::Forward), None); + } + + #[test] + fn opcode_search_input_mode_handles_keys_and_blocks_normal_commands() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![ + step(1), + step_with_immediate(2, OpCode::PUSH4, &[0x95, 0xd8, 0x9b, 0x41]), + step_with_stack(3, OpCode::MSTORE, &[]), + ], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + assert!(matches!(tui.handle_key_event(key(KeyCode::Char('/'))), ControlFlow::Continue(()))); + assert_eq!(tui.opcode_search_input.as_deref(), Some("")); + + let _ = tui.handle_key_event(key(KeyCode::Char('q'))); + assert_eq!(tui.opcode_search_input.as_deref(), Some("q")); + assert_eq!(tui.current_step, 0); + + let _ = tui.handle_key_event(key(KeyCode::Backspace)); + let _ = tui.handle_key_event(key(KeyCode::Char('9'))); + let _ = tui.handle_key_event(key(KeyCode::Char('5'))); + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.opcode_search_input, None); + assert_eq!(tui.last_opcode_search.as_deref(), Some("95")); + assert_eq!(tui.current_step, 1); + assert_eq!(tui.status.as_ref().unwrap().kind, StatusKind::Info); + } + + #[test] + fn opcode_search_repeats_forward_and_backward() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![ + step_with_stack(1, OpCode::MSTORE, &[]), + step(2), + step_with_stack(3, OpCode::MSTORE, &[]), + ], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + for c in "mstore".chars() { + let _ = tui.handle_key_event(key(KeyCode::Char(c))); + } + let _ = tui.handle_key_event(key(KeyCode::Enter)); + assert_eq!(tui.current_step, 2); + + let _ = tui.handle_key_event(key(KeyCode::Char('n'))); + assert_eq!(tui.current_step, 0); + + let _ = tui.handle_key_event(key(KeyCode::Char('N'))); + assert_eq!(tui.current_step, 2); + } + + #[test] + fn opcode_search_escape_cancels_without_moving() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + let _ = tui.handle_key_event(key(KeyCode::Char('s'))); + let _ = tui.handle_key_event(key(KeyCode::Esc)); + + assert_eq!(tui.opcode_search_input, None); + assert_eq!(tui.last_opcode_search, None); + assert_eq!(tui.current_step, 0); + assert_eq!(tui.status, None); + } + + #[test] + fn opcode_search_reports_empty_input_without_remembering_search() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.current_step, 0); + assert_eq!(tui.last_opcode_search, None); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Enter an opcode search term"); + } + + #[test] + fn opcode_search_reports_repeat_without_previous_search() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('n'))); + + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "No previous opcode search"); + } + + #[test] + fn opcode_search_reports_no_match_without_moving() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + for c in "sload".chars() { + let _ = tui.handle_key_event(key(KeyCode::Char(c))); + } + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.current_step, 0); + assert_eq!(tui.last_opcode_search.as_deref(), Some("sload")); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "No opcode matching `sload` in current call"); + } + #[test] fn memory_write_start_line_uses_write_offset() { assert_eq!(memory_write_start_line(&step_with_stack(0, OpCode::MSTORE, &[0, 96])), Some(3)); diff --git a/crates/debugger/src/tui/draw.rs b/crates/debugger/src/tui/draw.rs index b5ef2cbea6689..52bb6448d77a7 100644 --- a/crates/debugger/src/tui/draw.rs +++ b/crates/debugger/src/tui/draw.rs @@ -8,7 +8,7 @@ use foundry_common::fmt::format_token; use foundry_compilers::artifacts::sourcemap::SourceElement; use foundry_evm_core::buffer::{BufferKind, get_buffer_accesses}; use foundry_evm_traces::debug::{ - DebugSourceScope, DebugVariable, SourceData, decode_step_parameters, + DebugSourceScope, DebugVariable, SourceData, decode_step_parameters, function_signature, }; use ratatui::{ Frame, @@ -195,7 +195,9 @@ impl TUIContext<'_> { } fn footer_height(&self) -> u16 { - let status_or_input = u16::from(self.pc_input.is_some() || self.status.is_some()); + let status_or_input = u16::from( + self.pc_input.is_some() || self.opcode_search_input.is_some() || self.status.is_some(), + ); let shortcuts = if self.show_shortcuts { 2 } else { 0 }; status_or_input + shortcuts } @@ -216,6 +218,19 @@ impl TUIContext<'_> { Style::new().add_modifier(Modifier::DIM), ), ])); + } else if let Some(input) = &self.opcode_search_input { + lines.push(Line::from(vec![ + Span::styled( + "Search opcodes: /", + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(input.as_str()), + Span::styled("█", Style::new().fg(Color::Cyan)), + Span::styled( + " Enter: jump | Esc: cancel | after search: n/N repeat", + Style::new().add_modifier(Modifier::DIM), + ), + ])); } else if let Some(status) = &self.status { let style = match status.kind { StatusKind::Info => Style::new().fg(Color::Green), @@ -224,8 +239,8 @@ impl TUIContext<'_> { lines.push(Line::from(Span::styled(status.text.as_str(), style))); } - let l1 = "[q]: quit | [k/j]: prev/next op | [a/s]: prev/next jump | [c/C]: prev/next call | [g/G]: start/end | [p]: goto PC | [b]: cycle memory/calldata/returndata buffers"; - let l2 = "[l]: layout | [t]: stack labels | [m]: buffer decoding | [shift + j/k]: scroll stack | [ctrl + j/k]: scroll buffer | [']: goto breakpoint | [h] toggle help"; + let l1 = "[q]: quit | [k/j]: prev/next op | [a/s]: prev/next jump | [c/C]: prev/next call | [g/G]: start/end | [p]: goto PC | [/]: search opcodes | [n/N]: next/prev search"; + let l2 = "[l]: layout | [b]: cycle buffer | [t]: stack labels | [m]: buffer decoding | [shift + j/k]: scroll stack | [ctrl + j/k]: scroll buffer | [']: goto breakpoint | [h] toggle help"; let dimmed = Style::new().add_modifier(Modifier::DIM); if self.show_shortcuts { lines.push(Line::from(Span::styled(l1, dimmed))); @@ -876,9 +891,17 @@ fn variable_name(variable: &DebugVariable, index: usize, fallback_prefix: &str) fn decoded_internal_name_matches(decoded_name: &str, scope: &DebugSourceScope) -> bool { if let Some((contract_name, function_name)) = decoded_name.rsplit_once("::") { - return contract_name == scope.contract_name && function_name == scope.function_name; + return contract_name == scope.contract_name + && decoded_function_matches(function_name, scope); + } + decoded_function_matches(decoded_name, scope) +} + +fn decoded_function_matches(decoded_name: &str, scope: &DebugSourceScope) -> bool { + if decoded_name == scope.function_name { + return true; } - decoded_name == scope.function_name + scope_function_signature(scope).as_deref().is_some_and(|signature| decoded_name == signature) } fn decode_external_parameter_values( @@ -914,20 +937,6 @@ fn function_selector(function_name: &str, types: &[DynSolType]) -> [u8; 4] { keccak256(signature.as_bytes())[..4].try_into().unwrap() } -fn function_signature(function_name: &str, types: &[DynSolType]) -> String { - let mut signature = String::new(); - signature.push_str(function_name); - signature.push('('); - for (i, ty) in types.iter().enumerate() { - if i > 0 { - signature.push(','); - } - signature.push_str(&ty.sol_type_name()); - } - signature.push(')'); - signature -} - fn decode_abi_sequence(types: &[DynSolType], data: &[u8]) -> Option> { if types.is_empty() { return Some(Vec::new()); @@ -1116,13 +1125,18 @@ mod tests { } fn internal_call_step(end_step: usize, return_data: Vec) -> CallTraceStep { + internal_call_step_named("DebugMe::foo", end_step, Some(Vec::new()), Some(return_data)) + } + + fn internal_call_step_named( + func_name: &str, + end_step: usize, + args: Option>, + return_data: Option>, + ) -> CallTraceStep { let mut step = trace_step(Vec::new()); step.decoded = Some(Box::new(DecodedTraceStep::InternalCall( - DecodedInternalCall { - func_name: "DebugMe::foo".to_string(), - args: Some(Vec::new()), - return_data: Some(return_data), - }, + DecodedInternalCall { func_name: func_name.to_string(), args, return_data }, end_step, ))); step @@ -1242,6 +1256,43 @@ mod tests { ); } + #[test] + fn decode_internal_parameter_values_accepts_matching_overload_args() { + let mut context = context_with_arena(vec![debug_node( + 0, + 0, + vec![internal_call_step_named( + "DebugMe::foo(uint256)", + 2, + Some(vec!["42".to_string()]), + None, + )], + )]); + let mut tui = TUIContext::new(&mut context); + + assert_eq!( + tui.decode_internal_parameter_values(&scope("foo", "(uint256 amount)")), + Some(vec!["42".to_string()]) + ); + } + + #[test] + fn decode_internal_parameter_values_rejects_wrong_overload_args() { + let mut context = context_with_arena(vec![debug_node( + 0, + 0, + vec![internal_call_step_named( + "DebugMe::foo(address)", + 2, + Some(vec!["0x000000000000000000000000000000000000002a".to_string()]), + None, + )], + )]); + let mut tui = TUIContext::new(&mut context); + + assert_eq!(tui.decode_internal_parameter_values(&scope("foo", "(uint256 amount)")), None); + } + #[test] fn decode_return_values_uses_absolute_internal_call_end_step() { let mut context = context_with_arena(vec![debug_node( @@ -1268,6 +1319,27 @@ mod tests { assert_eq!(tui.decode_return_values(&scope("foo", "()")), Some(vec!["7".to_string()])); } + #[test] + fn decode_return_values_rejects_wrong_overload() { + let mut context = context_with_arena(vec![debug_node( + 0, + 0, + vec![ + internal_call_step_named( + "DebugMe::foo(address)", + 1, + None, + Some(vec!["99".to_string()]), + ), + trace_step(Vec::new()), + ], + )]); + let mut tui = TUIContext::new(&mut context); + tui.current_step = 1; + + assert_eq!(tui.decode_return_values(&scope("foo", "(uint256 amount)")), None); + } + #[test] fn active_internal_call_caches_by_current_node_and_step() { let mut context = context_with_arena(vec![debug_node( @@ -1298,6 +1370,15 @@ mod tests { assert!(!super::decoded_internal_name_matches("Other::foo", &scope)); } + #[test] + fn decoded_internal_name_matches_canonical_signature_for_overloads() { + let scope = scope("foo", "(uint256 amount)"); + + assert!(super::decoded_internal_name_matches("DebugMe::foo(uint256)", &scope)); + assert!(!super::decoded_internal_name_matches("DebugMe::foo(address)", &scope)); + assert!(!super::decoded_internal_name_matches("Other::foo(uint256)", &scope)); + } + #[test] fn scope_variable_line_marks_unavailable_locals() { let variable = super::ScopeVariable { diff --git a/crates/evm/core/src/evm/tempo.rs b/crates/evm/core/src/evm/tempo.rs index c7797ed05380e..1aca7d80e4bc5 100644 --- a/crates/evm/core/src/evm/tempo.rs +++ b/crates/evm/core/src/evm/tempo.rs @@ -13,7 +13,10 @@ use revm::{ state::Bytecode, }; use tempo_evm::{TempoBlockEnv, TempoEvmFactory, TempoHaltReason, evm::TempoEvm}; -use tempo_precompiles::{extend_tempo_precompiles, storage::StorageCtx}; +use tempo_precompiles::{ + extend_tempo_precompiles, + storage::{StorageActions, StorageCtx}, +}; use tempo_revm::{ TempoInvalidTransaction, TempoTxEnv, evm::TempoContext, gas_params::tempo_gas_params, handler::TempoEvmHandler, @@ -46,23 +49,32 @@ pub(crate) fn initialize_tempo_evm< is_forked: bool, ) { let ctx = evm.ctx_mut(); - StorageCtx::enter_evm(&mut ctx.journaled_state, &ctx.block, &ctx.cfg, &ctx.tx, || { - if is_forked { - // In fork mode, warm up precompile accounts to avoid repeated RPC fetches. - let mut sctx = StorageCtx; - let sentinel = Bytecode::new_legacy(Bytes::from_static(&[0xef])); - for addr in - TEMPO_PRECOMPILE_ADDRESSES.iter().copied().chain(TEMPO_TIP20_TOKENS.iter().copied()) - { - sctx.set_code(addr, sentinel.clone()) - .expect("failed to warm tempo precompile address"); + StorageCtx::enter_evm( + &mut ctx.journaled_state, + &ctx.block, + &ctx.cfg, + &ctx.tx, + StorageActions::disabled(), + || { + if is_forked { + // In fork mode, warm up precompile accounts to avoid repeated RPC fetches. + let mut sctx = StorageCtx; + let sentinel = Bytecode::new_legacy(Bytes::from_static(&[0xef])); + for addr in TEMPO_PRECOMPILE_ADDRESSES + .iter() + .copied() + .chain(TEMPO_TIP20_TOKENS.iter().copied()) + { + sctx.set_code(addr, sentinel.clone()) + .expect("failed to warm tempo precompile address"); + } + } else { + // In non-fork mode, run full genesis initialization. + initialize_tempo_test_genesis_inner(TEST_CONTRACT_ADDRESS, CALLER) + .expect("tempo genesis initialization failed"); } - } else { - // In non-fork mode, run full genesis initialization. - initialize_tempo_test_genesis_inner(TEST_CONTRACT_ADDRESS, CALLER) - .expect("tempo genesis initialization failed"); - } - }); + }, + ); } impl FoundryEvmFactory for TempoEvmFactory { @@ -89,7 +101,7 @@ impl FoundryEvmFactory for TempoEvmFactory { let networks = tempo_evm.inspector().get_networks(); networks.inject_precompiles(tempo_evm.precompiles_mut()); let cfg = tempo_evm.cfg.clone(); - extend_tempo_precompiles(tempo_evm.precompiles_mut(), &cfg); + extend_tempo_precompiles(tempo_evm.precompiles_mut(), &cfg, StorageActions::disabled()); initialize_tempo_evm(&mut tempo_evm, is_forked); tempo_evm diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 7ba300e3d0dbb..7efb8f64b1ad3 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -160,12 +160,12 @@ struct InvariantThroughputMetrics { } impl InvariantThroughputMetrics { - fn tx_per_sec(self, elapsed: Duration) -> f64 { - rate_per_sec(self.total_txs as f64, elapsed) + fn tps(self, elapsed: Duration) -> f64 { + round_rate_for_progress(rate_per_sec(self.total_txs as f64, elapsed)) } - fn gas_per_sec(self, elapsed: Duration) -> f64 { - rate_per_sec(self.total_gas as f64, elapsed) + fn gps(self, elapsed: Duration) -> f64 { + round_rate_for_progress(rate_per_sec(self.total_gas as f64, elapsed)) } } @@ -268,6 +268,10 @@ fn rate_per_sec(total: f64, elapsed: Duration) -> f64 { if elapsed_secs > 0.0 { total / elapsed_secs } else { 0.0 } } +fn round_rate_for_progress(rate: f64) -> f64 { + (rate * 100.0).round() / 100.0 +} + /// Tracks invariant failure counts during a campaign. #[derive(Clone, Debug, Default)] struct InvariantFailureMetrics { @@ -312,6 +316,16 @@ fn record_new_invariant_failures( } } +struct InvariantProgressContext<'a> { + timestamp_secs: u64, + contract_name: &'a str, + optimization_best: Option, + throughput: InvariantThroughputMetrics, + elapsed: Duration, + worker_id: u32, + worker_count: usize, +} + /// Builds the machine-readable invariant progress payload emitted during a /// campaign. /// @@ -319,35 +333,32 @@ fn record_new_invariant_failures( /// derived throughput fields so downstream benchmark tooling can consume a /// single JSON event shape. fn build_invariant_progress_json( - timestamp_secs: u64, - invariant_name: &str, + context: InvariantProgressContext<'_>, corpus_metrics: &M, - optimization_best: Option, - throughput: InvariantThroughputMetrics, failure_metrics: &InvariantFailureMetrics, - elapsed: Duration, ) -> serde_json::Value { let mut metrics = serde_json::to_value(corpus_metrics).unwrap_or_default(); if let Some(obj) = metrics.as_object_mut() { - obj.insert("failures".to_string(), json!(failure_metrics.failures)); - obj.insert("unique_failures".to_string(), json!(failure_metrics.unique_failures.len())); - // Surface unique handler-side assertion bugs in live progress, separate from - // invariant predicate violations counted by `failures`. - obj.insert("broken_handlers".to_string(), json!(failure_metrics.broken_handlers)); + obj.insert("broken_invariants".to_string(), json!(failure_metrics.unique_failures.len())); + obj.insert("broken_assertions".to_string(), json!(failure_metrics.broken_handlers)); } let mut payload = json!({ - "timestamp": timestamp_secs, + "timestamp": context.timestamp_secs, "event": "pulse", - "invariant": invariant_name, + "contract": context.contract_name, "metrics": metrics, - "total_txs": throughput.total_txs, - "total_gas": throughput.total_gas, - "tx_per_sec": throughput.tx_per_sec(elapsed), - "gas_per_sec": throughput.gas_per_sec(elapsed), + "total_txs": context.throughput.total_txs, + "total_gas": context.throughput.total_gas, + "tps": context.throughput.tps(context.elapsed), + "gps": context.throughput.gps(context.elapsed), + "worker": { + "id": context.worker_id, + "count": context.worker_count, + }, }); - if let Some(best) = optimization_best { + if let Some(best) = context.optimization_best { payload["optimization_best"] = json!(best.to_string()); } @@ -715,6 +726,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { corpus_seed .clone_for_worker(worker_plan.worker_id as usize, actual_worker_count), corpus_persistence, + actual_worker_count, gas_report_samples, ); debug!("finished in {:?}", timer.elapsed()); @@ -741,6 +753,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { campaign_seed, corpus_seed.clone(), corpus_persistence, + actual_worker_count, gas_report_samples, )?] }; @@ -790,6 +803,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { campaign_seed: InvariantCampaignSeed, corpus_seed: WorkerCorpusSeed, corpus_persistence: InvariantCorpusPersistence, + worker_count: usize, gas_report_samples: usize, ) -> Result { // Note: invariant function signatures (no inputs) are validated upstream in the @@ -1196,13 +1210,17 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { let throughput = InvariantThroughputMetrics { total_txs, total_gas }; // Display corpus metrics inline as JSON. let metrics = build_invariant_progress_json( - SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), - &invariant_contract.anchor().name, + InvariantProgressContext { + timestamp_secs: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + contract_name: invariant_contract.name, + optimization_best: invariant_test.test_data.optimization_best_value, + throughput, + elapsed: campaign_state.elapsed(), + worker_id: plan.worker_id, + worker_count, + }, &corpus_manager.metrics, - invariant_test.test_data.optimization_best_value, - throughput, &failure_metrics, - campaign_state.elapsed(), ); let _ = sh_println!("{}", serde_json::to_string(&metrics)?); } @@ -1962,23 +1980,33 @@ mod tests { let throughput = InvariantThroughputMetrics { total_txs: 2, total_gas: 50 }; let payload = build_invariant_progress_json( - 123, - "invariant_balance", + InvariantProgressContext { + timestamp_secs: 123, + contract_name: "InvariantContract", + optimization_best: Some(I256::try_from(42).unwrap()), + throughput, + elapsed: Duration::from_secs(10), + worker_id: 1, + worker_count: 4, + }, &json!({ "corpus_count": 7 }), - Some(I256::try_from(42).unwrap()), - throughput, &InvariantFailureMetrics::default(), - Duration::from_secs(10), ); assert_eq!(payload["timestamp"], json!(123)); - assert_eq!(payload["invariant"], json!("invariant_balance")); + assert_eq!(payload["contract"], json!("InvariantContract")); + assert!(payload.get("invariant").is_none()); assert_eq!(payload["metrics"]["corpus_count"], json!(7)); - assert_eq!(payload["metrics"]["broken_handlers"], json!(0)); + assert_eq!(payload["metrics"]["broken_assertions"], json!(0)); + assert!(payload["metrics"].get("broken_handlers").is_none()); assert_eq!(payload["total_txs"], json!(2)); assert_eq!(payload["total_gas"], json!(50)); - assert!((payload["tx_per_sec"].as_f64().unwrap() - 0.2).abs() < 1e-12); - assert!((payload["gas_per_sec"].as_f64().unwrap() - 5.0).abs() < 1e-12); + assert_eq!(payload["tps"], json!(0.2)); + assert_eq!(payload["gps"], json!(5.0)); + assert!(payload.get("tx_per_sec").is_none()); + assert!(payload.get("gas_per_sec").is_none()); + assert_eq!(payload["worker"]["id"], json!(1)); + assert_eq!(payload["worker"]["count"], json!(4)); assert_eq!(payload["optimization_best"], json!("42")); } @@ -2107,22 +2135,46 @@ mod tests { let throughput = InvariantThroughputMetrics { total_txs: 1, total_gas: 21_000 }; let payload = build_invariant_progress_json( - 456, - "invariant_zero_elapsed", + InvariantProgressContext { + timestamp_secs: 456, + contract_name: "invariant_zero_elapsed", + optimization_best: None, + throughput, + elapsed: Duration::ZERO, + worker_id: 0, + worker_count: 1, + }, &json!({ "corpus_count": 1 }), - None, - throughput, &InvariantFailureMetrics::default(), - Duration::ZERO, ); - assert_eq!(payload["tx_per_sec"], json!(0.0)); - assert_eq!(payload["gas_per_sec"], json!(0.0)); + assert_eq!(payload["tps"], json!(0.0)); + assert_eq!(payload["gps"], json!(0.0)); assert!(payload.get("optimization_best").is_none()); } #[test] - fn invariant_progress_json_includes_failure_counts() { + fn invariant_progress_json_rounds_fractional_rates() { + let payload = build_invariant_progress_json( + InvariantProgressContext { + timestamp_secs: 456, + contract_name: "TestContract", + optimization_best: None, + throughput: InvariantThroughputMetrics { total_txs: 1, total_gas: 1 }, + elapsed: Duration::from_secs(3), + worker_id: 0, + worker_count: 1, + }, + &json!({ "corpus_count": 1 }), + &InvariantFailureMetrics::default(), + ); + + assert_eq!(payload["tps"], json!(0.33)); + assert_eq!(payload["gps"], json!(0.33)); + } + + #[test] + fn invariant_progress_json_includes_broken_counts() { let mut failure_metrics = InvariantFailureMetrics::default(); failure_metrics.record_failure("invariant_a", "TestContract", "revert"); failure_metrics.record_failure("invariant_a", "TestContract", "revert"); @@ -2130,18 +2182,24 @@ mod tests { failure_metrics.broken_handlers = 7; let payload = build_invariant_progress_json( - 789, - "invariant_a", + InvariantProgressContext { + timestamp_secs: 789, + contract_name: "TestContract", + optimization_best: None, + throughput: InvariantThroughputMetrics::default(), + elapsed: Duration::from_secs(1), + worker_id: 0, + worker_count: 1, + }, &json!({ "corpus_count": 5 }), - None, - InvariantThroughputMetrics::default(), &failure_metrics, - Duration::from_secs(1), ); - assert_eq!(payload["metrics"]["failures"], json!(3)); - assert_eq!(payload["metrics"]["unique_failures"], json!(2)); - assert_eq!(payload["metrics"]["broken_handlers"], json!(7)); + assert!(payload["metrics"].get("failures").is_none()); + assert!(payload["metrics"].get("unique_failures").is_none()); + assert_eq!(payload["metrics"]["broken_invariants"], json!(2)); + assert_eq!(payload["metrics"]["broken_assertions"], json!(7)); + assert!(payload["metrics"].get("broken_handlers").is_none()); } #[test] diff --git a/crates/evm/traces/src/debug/mod.rs b/crates/evm/traces/src/debug/mod.rs index 4d9bbfa121877..acf26cac1b1fa 100644 --- a/crates/evm/traces/src/debug/mod.rs +++ b/crates/evm/traces/src/debug/mod.rs @@ -206,7 +206,8 @@ impl<'a> DebugStepsWalker<'a> { /// Tries to parse the function name from the source code and detect the contract name which /// contains the given function. /// -/// Returns string in the format `Contract::function`. +/// Returns a string in the format `Contract::function(types)` when parameters can be resolved, +/// falling back to `Contract::function`. fn parse_function_from_loc(source: &SourceData, loc: &SourceElement) -> Option { let start = loc.offset() as usize; let (source_part, end) = source_span(&source.source, start, loc.length() as usize)?; @@ -217,7 +218,41 @@ fn parse_function_from_loc(source: &SourceData, loc: &SourceElement) -> Option String { + let signature = canonical_function_signature(function_name, source_part) + .unwrap_or_else(|| function_name.to_string()); + format!("{contract_name}::{signature}") +} + +fn canonical_function_signature(function_name: &str, source_part: &str) -> Option { + let source_part = source_part.replace('\n', ""); + let (inputs, _) = parse_types(&source_part); + let inputs = inputs?; + let types = + inputs.params.iter().map(|param| param.resolve().ok()).collect::>>()?; + Some(function_signature(function_name, &types)) +} + +/// Formats an ABI-style function signature from a name and canonical parameter types. +pub fn function_signature(function_name: &str, types: &[DynSolType]) -> String { + let mut signature = String::new(); + signature.push_str(function_name); + signature.push('('); + for (i, ty) in types.iter().enumerate() { + if i > 0 { + signature.push(','); + } + signature.push_str(&ty.sol_type_name()); + } + signature.push(')'); + signature } fn source_span(source: &str, start: usize, len: usize) -> Option<(&str, usize)> { @@ -347,7 +382,9 @@ fn memory_range(memory: &[u8], start: usize, len: usize) -> Option<&[u8]> { #[cfg(test)] mod tests { - use super::{decode_from_memory, decode_step_parameters, source_span}; + use super::{ + decode_from_memory, decode_step_parameters, internal_function_identifier, source_span, + }; use alloy_dyn_abi::{DynSolType, parser::Parameters}; use alloy_primitives::{Bytes, U256}; use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; @@ -379,6 +416,18 @@ mod tests { assert_eq!(source_span("abcdef", usize::MAX, 1), None); } + #[test] + fn internal_function_identifier_includes_canonical_signature() { + assert_eq!( + internal_function_identifier( + "DebugMe", + "foo", + "function foo(uint256 amount, bool ok) internal returns (uint256) {", + ), + "DebugMe::foo(uint256,bool)" + ); + } + #[test] fn decode_from_memory_rejects_overflow_location() { assert_eq!(decode_from_memory(&DynSolType::Bytes, &[0; 64], usize::MAX), None); diff --git a/crates/evm/traces/src/folded_stack_trace.rs b/crates/evm/traces/src/folded_stack_trace.rs index 4fc43594e1b39..883d29bf23105 100644 --- a/crates/evm/traces/src/folded_stack_trace.rs +++ b/crates/evm/traces/src/folded_stack_trace.rs @@ -228,7 +228,34 @@ impl FoldedStackTraceBuilder { } } +#[cfg(test)] mod tests { + use alloy_primitives::{Bytes, U256}; + use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; + use revm_inspectors::tracing::{ + CallTraceArena, + types::{CallTraceStep, DecodedInternalCall, DecodedTraceStep, TraceMemberOrder}, + }; + + fn trace_step(gas_remaining: u64) -> CallTraceStep { + CallTraceStep { + pc: 0, + op: OpCode::STOP, + stack: Some(Vec::::new().into_boxed_slice()), + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + #[test] fn test_fst_1() { let mut trace = super::FoldedStackTraceBuilder::default(); @@ -321,4 +348,27 @@ mod tests { ] ); } + + #[test] + fn folded_stack_trace_keeps_precise_internal_function_names() { + let mut arena = CallTraceArena::default(); + let root = &mut arena.nodes_mut()[0]; + root.trace.gas_used = 100; + root.trace.gas_limit = 100; + root.trace.steps = vec![trace_step(100), trace_step(70)]; + root.trace.steps[0].decoded = Some(Box::new(DecodedTraceStep::InternalCall( + DecodedInternalCall { + func_name: "DebugVarsTest::foo(uint256)".to_string(), + args: Some(vec!["42".to_string()]), + return_data: Some(vec!["43".to_string()]), + }, + 1, + ))); + root.ordering = vec![TraceMemberOrder::Step(0), TraceMemberOrder::Step(1)]; + + assert_eq!( + super::build(&arena, false), + vec!["fallback 70", "fallback;DebugVarsTest::foo(uint256) 30",] + ); + } } diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index 06a502affeded..52bfbce7a8d66 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -24,7 +24,7 @@ use foundry_common::{ fmt::parse_tokens, provider::ProviderBuilder, shell, - tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_resolved_fee_token}, + tempo::{TEMPO_BROWSER_GAS_BUFFER, maybe_print_fee_token, resolve_and_set_fee_token}, }; use foundry_compilers::{ ArtifactId, artifacts::BytecodeObject, info::ContractInfo, utils::canonicalize, @@ -575,14 +575,25 @@ impl CreateArgs { let tempo_sponsor = self.tx.tempo.sponsor_config().await?; if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + resolve_unknown_fee_token_symbol.then_some(&provider), + Some(chain), + &mut deployer.tx, + ) + .await?; sponsor.attach_and_print::(&mut deployer.tx, deployer_address).await?; + } else { + let fee_token = resolve_and_set_fee_token( + resolve_unknown_fee_token_symbol.then_some(&provider), + Some(chain), + &mut deployer.tx, + Some(deployer_address), + ) + .await?; + maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(&provider), fee_token) + .await?; } - maybe_print_resolved_fee_token( - resolve_unknown_fee_token_symbol.then_some(&provider), - Some(chain), - deployer.tx.fee_token(), - ) - .await?; // Deploy the actual contract let (deployed_contract, receipt) = if let Some(browser) = browser_signer { diff --git a/crates/forge/tests/cli/failure_assertions.rs b/crates/forge/tests/cli/failure_assertions.rs index e9412f8df1ead..942ed6f854db9 100644 --- a/crates/forge/tests/cli/failure_assertions.rs +++ b/crates/forge/tests/cli/failure_assertions.rs @@ -209,7 +209,7 @@ forgetest!(flaky_expect_emit_tests_should_fail, |prj, cmd| { cmd.forge_fuse().args(["test", "--mc", "ExpectEmitFailureTest"]).assert_failure().stdout_eq(str![[r#"[COMPILING_FILES] with [SOLC_VERSION] ... [FAIL: E != expected A] testShouldFailCanMatchConsecutiveEvents() ([GAS]) -[FAIL: log != expected SomethingElse] testShouldFailDifferentIndexedParameters() ([GAS]) +[FAIL: SomethingElse indexed topic count mismatch: expected 1, got 0] testShouldFailDifferentIndexedParameters() ([GAS]) [FAIL: log != expected log] testShouldFailEmitOnlyAppliesToNextCall() ([GAS]) [FAIL: next call did not revert as expected] testShouldFailEmitWindowWithRevertDisallowed() ([GAS]) [FAIL: E != expected A] testShouldFailEventsOnTwoCalls() ([GAS]) @@ -235,7 +235,7 @@ Suite result: FAILED. 0 passed; 15 failed; 0 skipped; [ELAPSED] ... [FAIL: log != expected log] testShouldFailCountEmitsFromAddress() ([GAS]) [FAIL: log != expected log] testShouldFailCountLessEmits() ([GAS]) -[FAIL: log != expected Something] testShouldFailEmitSomethingElse() ([GAS]) +[FAIL: SomethingElse != expected Something] testShouldFailEmitSomethingElse() ([GAS]) [FAIL: log emitted but expected 0 times] testShouldFailNoEmit() ([GAS]) [FAIL: log emitted but expected 0 times] testShouldFailNoEmitFromAddress() ([GAS]) Suite result: FAILED. 0 passed; 5 failed; 0 skipped; [ELAPSED] diff --git a/crates/forge/tests/cli/script.rs b/crates/forge/tests/cli/script.rs index 910cc1260189f..e8a06bcf0992a 100644 --- a/crates/forge/tests/cli/script.rs +++ b/crates/forge/tests/cli/script.rs @@ -1,6 +1,9 @@ //! Contains various tests related to `forge script`. -use crate::{constants::TEMPLATE_CONTRACT, utils::generate_large_runtime_contract}; +use crate::{ + constants::TEMPLATE_CONTRACT, + utils::{assert_debug_dump_identifies_contract, generate_large_runtime_contract}, +}; use alloy_hardforks::EthereumHardfork; use alloy_network::Ethereum; use alloy_primitives::{Address, Bytes, address, hex}; @@ -43,6 +46,83 @@ contract ContractScript is Script { } ); +forgetest_async!(script_debug_dump_identifies_contracts_loaded_from_fork, |prj, cmd| { + prj.add_source( + "ScriptForkDebugTarget.sol", + r#" +contract ScriptForkDebugTarget { + uint256 public value; + + function set(uint256 newValue) public { + value = newValue; + } +} +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let pk = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + let create_output = cmd + .forge_fuse() + .args([ + "create", + "./src/ScriptForkDebugTarget.sol:ScriptForkDebugTarget", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk, + "--broadcast", + "--json", + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let create_output: Value = serde_json::from_slice(&create_output).unwrap(); + let deployed = create_output["deployedTo"].as_str().unwrap().to_owned(); + + prj.add_script( + "DebugRemote.s.sol", + &format!( + r#" +interface IScriptForkDebugTarget {{ + function set(uint256 newValue) external; + function value() external view returns (uint256); +}} + +contract DebugRemote {{ + IScriptForkDebugTarget private target = IScriptForkDebugTarget({deployed}); + + function setUp() public {{ + target.set(7); + require(target.value() == 7, "setup value"); + }} + + function run() public {{ + target.set(19); + require(target.value() == 19, "value"); + }} +}} +"# + ), + ); + + let dump_path = prj.root().join("script_dump.json"); + cmd.forge_fuse().args([ + "script", + "script/DebugRemote.s.sol:DebugRemote", + "--fork-url", + rpc.as_str(), + "--debug", + "--dump", + dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + assert_debug_dump_identifies_contract(&dump_path, &deployed, "ScriptForkDebugTarget"); +}); + // Tests that the `run` command works correctly forgetest!(can_execute_script_command2, |prj, cmd| { let script = prj.add_source( diff --git a/crates/forge/tests/cli/test_cmd/mod.rs b/crates/forge/tests/cli/test_cmd/mod.rs index 4f7b8cfba58f1..d0f7827dcb48e 100644 --- a/crates/forge/tests/cli/test_cmd/mod.rs +++ b/crates/forge/tests/cli/test_cmd/mod.rs @@ -1,5 +1,6 @@ //! Contains various tests for `forge test`. +use crate::utils::assert_debug_dump_identifies_contract; use alloy_primitives::{Address, U256}; use anvil::{NodeConfig, spawn}; use foundry_test_utils::{ @@ -9,11 +10,7 @@ use foundry_test_utils::{ util::{OTHER_SOLC_VERSION, OutputExt, SOLC_VERSION}, }; use similar_asserts::assert_eq; -use std::{ - io::Write, - path::{Path, PathBuf}, - str::FromStr, -}; +use std::{io::Write, path::PathBuf, str::FromStr}; mod core; mod fuzz; @@ -58,6 +55,32 @@ fn setup_testdata_cmd(cmd: &mut TestCommand) { drop(dotenv); } +fn collect_debug_dump_internal_calls<'a>( + value: &'a serde_json::Value, + calls: &mut Vec<&'a serde_json::Value>, +) { + match value { + serde_json::Value::Array(values) => { + for value in values { + collect_debug_dump_internal_calls(value, calls); + } + } + serde_json::Value::Object(map) => { + if let Some(call) = map + .get("InternalCall") + .and_then(|value| value.as_array()) + .and_then(|values| values.first()) + { + calls.push(call); + } + for value in map.values() { + collect_debug_dump_internal_calls(value, calls); + } + } + _ => {} + } +} + /// Contracts excluded from the main `testdata` run because they depend on flaky external RPCs. /// These are run separately by the `flaky_testdata` test below. /// Format: pipe-separated regex alternation, e.g. `"Foo|Bar|Baz"`. @@ -1497,13 +1520,13 @@ Traces: ├─ [165406] → new SimpleContract@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f │ └─ ← [Return] 826 bytes of code ├─ [22630] SimpleContract::increment() - │ ├─ [20147] SimpleContract::_setNum(1) + │ ├─ [20147] SimpleContract::_setNum(uint256)(1) │ │ └─ ← 0 │ └─ ← [Stop] ├─ [23204] SimpleContract::setValues(100, 0x0000000000000000000000000000000000000123) - │ ├─ [247] SimpleContract::_setNum(100) + │ ├─ [247] SimpleContract::_setNum(uint256)(100) │ │ └─ ← 1 - │ ├─ [22336] SimpleContract::_setAddr(0x0000000000000000000000000000000000000123) + │ ├─ [22336] SimpleContract::_setAddr(address)(0x0000000000000000000000000000000000000123) │ │ └─ ← 0x0000000000000000000000000000000000000000 │ └─ ← [Stop] └─ ← [Stop] @@ -1553,7 +1576,7 @@ Traces: ├─ [..] → new SimpleContract@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f │ └─ ← [Return] [..] bytes of code ├─ [..] SimpleContract::setStr("new value") - │ ├─ [..] SimpleContract::_setStr("new value") + │ ├─ [..] SimpleContract::_setStr(string)("new value") │ │ └─ ← "initial value" │ └─ ← [Stop] └─ ← [Stop] @@ -2670,6 +2693,70 @@ contract Dummy { assert!(dump_path.exists()); }); +forgetest!(debug_dump_disambiguates_overloaded_internal_functions, |prj, cmd| { + prj.add_source( + "DebugVars", + r" +contract DebugVarsTest { + function testOverloadedInternalDebugVars() public { + uint256 amount = foo(uint256(42)); + address who = foo(address(0x000000000000000000000000000000000000bEEF)); + + require(amount == 43, 'bad amount'); + require(who == address(0x000000000000000000000000000000000000bEEF), 'bad address'); + } + + function foo(uint256 amount) internal pure returns (uint256 out) { + uint256 next = amount + 1; + return next; + } + + function foo(address who) internal pure returns (address out) { + address seen = who; + return seen; + } +} +", + ); + + let dump_path = prj.root().join("overloads_dump.json"); + + cmd.args([ + "test", + "--mt", + "testOverloadedInternalDebugVars", + "--debug", + "--dump", + dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let mut calls = Vec::new(); + collect_debug_dump_internal_calls(&dump, &mut calls); + + let uint_call = calls + .iter() + .find(|call| call["func_name"] == "DebugVarsTest::foo(uint256)") + .expect("missing uint256 overload in debugger dump"); + assert_eq!(uint_call["args"], serde_json::json!(["42"])); + assert_eq!(uint_call["return_data"], serde_json::json!(["43"])); + + let address_call = calls + .iter() + .find(|call| call["func_name"] == "DebugVarsTest::foo(address)") + .expect("missing address overload in debugger dump"); + assert_eq!( + address_call["args"], + serde_json::json!(["0x000000000000000000000000000000000000bEEF"]) + ); + assert_eq!( + address_call["return_data"], + serde_json::json!(["0x000000000000000000000000000000000000bEEF"]) + ); +}); + // forgetest!(test_debug_with_dump_setup_revert, |prj, cmd| { prj.add_test( @@ -2853,17 +2940,6 @@ contract ForkDebugTest {{ assert_debug_dump_identifies_contract(&table_dump_path, &deployed, "ForkDebugTarget"); }); -fn assert_debug_dump_identifies_contract(dump_path: &Path, address: &str, contract_name: &str) { - let dump: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); - let identified = dump["contracts"]["identified_contracts"].as_object().unwrap(); - let target_identified = identified.iter().any(|(identified_address, name)| { - identified_address.eq_ignore_ascii_case(address) - && name.as_str().is_some_and(|name| name == contract_name) - }); - assert!(target_identified, "forked target was not identified in debugger dump: {identified:?}"); -} - forgetest_init!(test_assume_no_revert_with_data, |prj, cmd| { prj.update_config(|config| { config.fuzz.seed = Some(U256::from(111)); diff --git a/crates/forge/tests/cli/test_cmd/repros.rs b/crates/forge/tests/cli/test_cmd/repros.rs index 1f7d6c4b73e84..1b2d0f30c1cd7 100644 --- a/crates/forge/tests/cli/test_cmd/repros.rs +++ b/crates/forge/tests/cli/test_cmd/repros.rs @@ -255,14 +255,14 @@ contract Issue6170Test is Test { Compiler run successful! Ran 1 test for test/Issue6170.t.sol:Issue6170Test -[FAIL: Values != expected Values] test() ([GAS]) +[FAIL: Values indexed topic count mismatch: expected 1, got 2] test() ([GAS]) Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in test/Issue6170.t.sol:Issue6170Test -[FAIL: Values != expected Values] test() ([GAS]) +[FAIL: Values indexed topic count mismatch: expected 1, got 2] test() ([GAS]) Encountered a total of 1 failing tests, 0 tests succeeded diff --git a/crates/forge/tests/cli/test_optimizer.rs b/crates/forge/tests/cli/test_optimizer.rs index 58d86daca2854..b1fdeed3da6c7 100644 --- a/crates/forge/tests/cli/test_optimizer.rs +++ b/crates/forge/tests/cli/test_optimizer.rs @@ -1426,7 +1426,7 @@ Traces: │ └─ ← [Stop] ├─ [..] Counter::number() [staticcall] │ └─ ← [Return] 1 - ├─ [..] StdAssertions::assertEq(1, 1) + ├─ [..] StdAssertions::assertEq(uint256,uint256)(1, 1) │ └─ ← └─ ← [Stop] diff --git a/crates/forge/tests/cli/utils.rs b/crates/forge/tests/cli/utils.rs index 319fc5912ea78..58e96bc9de326 100644 --- a/crates/forge/tests/cli/utils.rs +++ b/crates/forge/tests/cli/utils.rs @@ -3,6 +3,7 @@ use alloy_chains::NamedChain; use alloy_primitives::Address; use alloy_signer_local::PrivateKeySigner; +use std::path::Path; /// Returns the current millis since unix epoch. /// @@ -182,6 +183,17 @@ pub fn parse_deployed_address(out: &str) -> Option { None } +pub fn assert_debug_dump_identifies_contract(dump_path: &Path, address: &str, contract_name: &str) { + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let identified = dump["contracts"]["identified_contracts"].as_object().unwrap(); + let target_identified = identified.iter().any(|(identified_address, name)| { + identified_address.eq_ignore_ascii_case(address) + && name.as_str().is_some_and(|name| name == contract_name) + }); + assert!(target_identified, "forked target was not identified in debugger dump: {identified:?}"); +} + pub fn parse_verification_guid(out: &str) -> Option { let mut lines = out.lines().map(str::trim).filter(|line| !line.is_empty()); let line = lines.next()?; diff --git a/crates/lint/README.md b/crates/lint/README.md index 1623eff7bb1b3..157dcb6ae4ac7 100644 --- a/crates/lint/README.md +++ b/crates/lint/README.md @@ -37,6 +37,7 @@ It helps enforce best practices and improve code quality within Foundry projects - `block-timestamp`: Warns when `block.timestamp` is used in a comparison, as it may be manipulated by validators. - `calls-loop`: External calls inside loops can cause denial-of-service if a call reverts or exhausts gas. - `delegatecall-loop`: Payable functions should not use `delegatecall` inside a loop. + - `incorrect-modifier`: Modifiers should not be able to finish without executing `_` or reverting. - `missing-events-access-control`: Access control changes should emit events. - `missing-zero-check`: Address parameter is used in a state write or value transfer without a zero-address check. - `reentrancy-events`: Events emitted after external calls can be reordered or fabricated by a reentrant callee and mislead off-chain consumers. diff --git a/crates/lint/docs/incorrect-modifier.md b/crates/lint/docs/incorrect-modifier.md new file mode 100644 index 0000000000000..2429a1fdd8806 --- /dev/null +++ b/crates/lint/docs/incorrect-modifier.md @@ -0,0 +1,47 @@ +# Incorrect Modifier + +**Severity**: `Low` +**ID**: `incorrect-modifier` + +Reports modifiers that can finish without executing the modified function body or reverting. + +## What it does + +Flags Solidity modifiers where at least one path can complete before reaching the `_` placeholder. +Paths that revert before `_` are not flagged. + +Only a path that provably reverts or otherwise halts is treated as safe: an explicit `revert` +statement or `revert(...)` builtin call, and the Yul halting builtins (`revert`/`invalid` to fail, +and `return`/`stop`/`selfdestruct`, which halt *successfully* without running the function body and +are therefore flagged). A regular function call that *might* revert (e.g. `require(...)`, an +internal helper, or an external call) does not count, so a path that performs such a call and then +returns without reaching `_` is still flagged. This is intentionally stricter than some other +tools. + +## Why is this bad? + +A modifier that falls through before `_` silently skips the function body. This can make calls look +successful even though the protected action never ran. + +## Example + +### Bad + +```solidity +modifier onlyWhenEnabled() { + if (enabled) { + _; + } +} +``` + +### Good + +```solidity +modifier onlyWhenEnabled() { + if (!enabled) { + revert Disabled(); + } + _; +} +``` diff --git a/crates/lint/src/linter.rs b/crates/lint/src/linter.rs index 0865147167243..c0bd5f2af735f 100644 --- a/crates/lint/src/linter.rs +++ b/crates/lint/src/linter.rs @@ -45,7 +45,7 @@ impl<'s> LintContext<'s> { } // Helper method to emit diagnostics easily from passes - pub fn emit(&self, lint: &'static L, span: Span) { + pub fn emit(&self, lint: &L, span: Span) { let desc = if self.desc { lint.description() } else { "" }; let diag: DiagBuilder<'_, ()> = self .sess diff --git a/crates/lint/src/sol/codesize/unwrapped_modifier_logic.rs b/crates/lint/src/sol/codesize/unwrapped_modifier_logic.rs index 994f66ab1865f..754c0f93f7962 100644 --- a/crates/lint/src/sol/codesize/unwrapped_modifier_logic.rs +++ b/crates/lint/src/sol/codesize/unwrapped_modifier_logic.rs @@ -1,7 +1,7 @@ use super::UnwrappedModifierLogic; use crate::{ linter::{LateLintPass, LintContext, Suggestion}, - sol::{Severity, SolLint}, + sol::{Severity, SolLint, low::incorrect_modifier}, }; use solar::{ ast, @@ -29,12 +29,27 @@ impl<'hir> LateLintPass<'hir> for UnwrappedModifierLogic { _ => return, }; + if incorrect_modifier::block_outcome(*body).can_skip_placeholder() { + return; + } + + // Only handle modifiers with exactly one placeholder, *and* require it to be top-level. + // Counting recursively (rather than just top-level statements) ensures a placeholder nested + // inside an `if`/loop/etc. is never extracted into a helper function, which would produce + // an invalid, behavior-changing rewrite. + if count_placeholders(body.stmts) != 1 { + return; + } + let Some(idx) = + body.stmts.iter().position(|s| matches!(s.kind, hir::StmtKind::Placeholder)) + else { + // The single placeholder is nested; splitting it out would be unsafe. + return; + }; + // Split statements into before and after the placeholder `_`. let stmts = body.stmts[..].as_ref(); - let (before, after) = stmts - .iter() - .position(|s| matches!(s.kind, hir::StmtKind::Placeholder)) - .map_or((stmts, &[][..]), |idx| (&stmts[..idx], &stmts[idx + 1..])); + let (before, after) = (&stmts[..idx], &stmts[idx + 1..]); // Generate a fix suggestion if the modifier logic should be wrapped. if let Some(suggestion) = self.get_snippet(ctx, hir, func, before, after) { @@ -181,3 +196,30 @@ impl UnwrappedModifierLogic { ) } } + +/// Recursively counts placeholder (`_`) statements within a list of statements, descending into +/// nested blocks, conditionals, loops, `try`/`catch`, and Yul `switch` cases. +fn count_placeholders(stmts: &[hir::Stmt<'_>]) -> usize { + stmts.iter().map(count_placeholders_in_stmt).sum() +} + +fn count_placeholders_in_stmt(stmt: &hir::Stmt<'_>) -> usize { + match &stmt.kind { + hir::StmtKind::Placeholder => 1, + hir::StmtKind::Block(block) + | hir::StmtKind::UncheckedBlock(block) + | hir::StmtKind::AssemblyBlock(block) + | hir::StmtKind::Loop(block, _) => count_placeholders(block.stmts), + hir::StmtKind::If(_, then_stmt, else_stmt) => { + count_placeholders_in_stmt(then_stmt) + + else_stmt.map_or(0, |s| count_placeholders_in_stmt(s)) + } + hir::StmtKind::Try(try_stmt) => { + try_stmt.clauses.iter().map(|clause| count_placeholders(clause.block.stmts)).sum() + } + hir::StmtKind::Switch(switch) => { + switch.cases.iter().map(|case| count_placeholders(case.body.stmts)).sum() + } + _ => 0, + } +} diff --git a/crates/lint/src/sol/low/incorrect_modifier.rs b/crates/lint/src/sol/low/incorrect_modifier.rs new file mode 100644 index 0000000000000..48e28062ebf2f --- /dev/null +++ b/crates/lint/src/sol/low/incorrect_modifier.rs @@ -0,0 +1,192 @@ +use super::IncorrectModifier; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast, + sema::{ + Gcx, Hir, + builtins::Builtin, + hir::{Block, Expr, ExprKind, Function, LoopSource, Res, Stmt, StmtKind}, + }, +}; + +declare_forge_lint!( + INCORRECT_MODIFIER, + Severity::Low, + "incorrect-modifier", + "modifier can finish without executing the modified function" +); + +impl<'hir> LateLintPass<'hir> for IncorrectModifier { + fn check_function( + &mut self, + ctx: &LintContext, + _gcx: Gcx<'hir>, + _hir: &'hir Hir<'hir>, + func: &'hir Function<'hir>, + ) { + let (ast::FunctionKind::Modifier, Some(body)) = (func.kind, func.body) else { + return; + }; + + if block_outcome(body).can_skip_placeholder() { + ctx.emit(&INCORRECT_MODIFIER, func.span); + } + } +} + +/// Summary of how control flow can leave a statement or block *without* having executed the +/// placeholder (`_`) or reverted. +/// +/// Each flag tracks whether there is at least one such path. If every path reaches `_` or reverts, +/// all flags are `false` ([`Outcome::COVERED`]). +#[derive(Clone, Copy)] +pub(crate) struct Outcome { + /// Control can reach the end of the construct normally and continue to the next statement. + falls_through: bool, + /// Control can exit the modifier via `return` before reaching `_`. + returns: bool, + /// Control can exit the enclosing loop via `break` before reaching `_`. + breaks: bool, + /// Control can jump to the enclosing loop's next iteration via `continue` before reaching `_`. + continues: bool, +} + +impl Outcome { + /// Every path reaches `_` or reverts. + const COVERED: Self = + Self { falls_through: false, returns: false, breaks: false, continues: false }; + const FALLTHROUGH: Self = Self { falls_through: true, ..Self::COVERED }; + const RETURNS: Self = Self { returns: true, ..Self::COVERED }; + const BREAKS: Self = Self { breaks: true, ..Self::COVERED }; + const CONTINUES: Self = Self { continues: true, ..Self::COVERED }; + + /// Whether the modifier body can finish without executing `_`. Only fall-through and `return` + /// reach the modifier's end; `break`/`continue` are always consumed by an enclosing loop. + pub(crate) const fn can_skip_placeholder(self) -> bool { + self.falls_through || self.returns + } + + const fn merge(self, other: Self) -> Self { + Self { + falls_through: self.falls_through || other.falls_through, + returns: self.returns || other.returns, + breaks: self.breaks || other.breaks, + continues: self.continues || other.continues, + } + } +} + +pub(crate) fn block_outcome(block: Block<'_>) -> Outcome { + let mut outcome = Outcome::FALLTHROUGH; + for stmt in block.stmts { + // Once a statement cannot fall through, the rest of the block is unreachable. + if !outcome.falls_through { + return outcome; + } + let stmt_outcome = stmt_outcome(stmt); + outcome = Outcome { + falls_through: stmt_outcome.falls_through, + returns: outcome.returns || stmt_outcome.returns, + breaks: outcome.breaks || stmt_outcome.breaks, + continues: outcome.continues || stmt_outcome.continues, + }; + } + outcome +} + +fn stmt_outcome(stmt: &Stmt<'_>) -> Outcome { + match &stmt.kind { + StmtKind::Placeholder => Outcome::COVERED, + StmtKind::Return(_) => Outcome::RETURNS, + StmtKind::Break => Outcome::BREAKS, + StmtKind::Continue => Outcome::CONTINUES, + StmtKind::Expr(expr) => call_outcome(expr).unwrap_or(Outcome::FALLTHROUGH), + StmtKind::Revert(_) => Outcome::COVERED, + StmtKind::Block(block) + | StmtKind::UncheckedBlock(block) + | StmtKind::AssemblyBlock(block) => block_outcome(*block), + StmtKind::If(_, then_stmt, else_stmt) => { + let then_outcome = stmt_outcome(then_stmt); + let else_outcome = else_stmt.map_or(Outcome::FALLTHROUGH, stmt_outcome); + then_outcome.merge(else_outcome) + } + StmtKind::Loop(block, source) => { + // `for`/`while`/`do-while` are all desugared to a `Loop` whose body holds the condition + // as a synthetic `else break`. The loop can be left (and thus fall through to the + // following statement) via a `break`, including that synthetic condition break; a loop + // without any `break` (e.g. `for (;;)`) never falls through. For `do-while` the + // condition sits *after* the body, so a `continue` in the body also reaches it and can + // exit the loop. `break`/`continue` are otherwise consumed by the loop; only `return` + // keeps escaping toward the modifier's end. + let body = block_outcome(*block); + let falls_through = + body.breaks || (matches!(source, LoopSource::DoWhile) && body.continues); + Outcome { falls_through, returns: body.returns, ..Outcome::COVERED } + } + StmtKind::Try(try_stmt) => { + // Every execution enters exactly one clause (the `returns` clause on success or a + // matching `catch`), or the call reverts uncaught. There is no implicit fall-through + // path that skips all clauses, so start from `COVERED`. + let mut outcome = Outcome::COVERED; + for clause in try_stmt.clauses { + outcome = outcome.merge(block_outcome(clause.block)); + } + outcome + } + StmtKind::Switch(switch) => { + // A Yul `switch` value that matches no `case` falls through unless a `default` clause + // is present (stored last, with no constant). + let has_default = switch.cases.last().is_some_and(|case| case.constant.is_none()); + let mut outcome = if has_default { Outcome::COVERED } else { Outcome::FALLTHROUGH }; + for case in switch.cases { + outcome = outcome.merge(block_outcome(case.body)); + } + outcome + } + StmtKind::DeclSingle(_) + | StmtKind::DeclMulti(_, _) + | StmtKind::Emit(_) + | StmtKind::Err(_) => Outcome::FALLTHROUGH, + } +} + +/// Classifies a statement-level call expression that terminates the current path before reaching +/// `_`, if any. Covers both the Solidity `revert`/`revert(...)` builtins and the Yul halting +/// builtins reachable when recursing into an `assembly { .. }` block. +/// +/// - Failing halts (`revert`, Yul `revert`/`invalid`) leave every path either reverting or reaching +/// `_`, so they are [`Outcome::COVERED`] (not flagged). +/// - Successful halts (Yul `return`/`stop`, `selfdestruct`) let the surrounding call finish +/// *without* running the modified function body, which is exactly what this lint flags, so they +/// behave like a `return` ([`Outcome::RETURNS`]). +fn call_outcome(expr: &Expr<'_>) -> Option { + let ExprKind::Call(callee, args, _) = &expr.peel_parens().kind else { return None }; + let ExprKind::Ident(resolutions) = &callee.peel_parens().kind else { return None }; + resolutions.iter().find_map(|res| match res { + Res::Builtin( + Builtin::Revert | Builtin::RevertMsg | Builtin::YulRevert | Builtin::YulInvalid, + ) => Some(Outcome::COVERED), + Res::Builtin(Builtin::Require | Builtin::Assert) + if args.exprs().next().is_some_and(is_literal_false) => + { + Some(Outcome::COVERED) + } + Res::Builtin( + Builtin::YulReturn + | Builtin::YulStop + | Builtin::YulSelfdestruct + | Builtin::Selfdestruct, + ) => Some(Outcome::RETURNS), + _ => None, + }) +} + +fn is_literal_false(expr: &Expr<'_>) -> bool { + matches!( + &expr.peel_parens().kind, + ExprKind::Lit(lit) if matches!(lit.kind, ast::LitKind::Bool(false)) + ) +} diff --git a/crates/lint/src/sol/low/mod.rs b/crates/lint/src/sol/low/mod.rs index ec99565a73e56..ce516419799e6 100644 --- a/crates/lint/src/sol/low/mod.rs +++ b/crates/lint/src/sol/low/mod.rs @@ -9,6 +9,9 @@ use calls_loop::CALLS_LOOP; mod delegatecall_loop; use delegatecall_loop::DELEGATECALL_LOOP; +pub(crate) mod incorrect_modifier; +use incorrect_modifier::INCORRECT_MODIFIER; + mod msg_value_loop; use msg_value_loop::MSG_VALUE_LOOP; @@ -33,6 +36,7 @@ register_lints!( (BlockTimestamp, late, (BLOCK_TIMESTAMP)), (CallsLoop, late, (CALLS_LOOP)), (DelegatecallLoop, late, (DELEGATECALL_LOOP)), + (IncorrectModifier, late, (INCORRECT_MODIFIER)), (MsgValueLoop, late, (MSG_VALUE_LOOP)), (MissingEventsAccessControl, late, (MISSING_EVENTS_ACCESS_CONTROL)), (MissingEventsArithmetic, late, (MISSING_EVENTS_ARITHMETIC)), diff --git a/crates/lint/testdata/IncorrectModifier.sol b/crates/lint/testdata/IncorrectModifier.sol new file mode 100644 index 0000000000000..68ea0ba9cf3ce --- /dev/null +++ b/crates/lint/testdata/IncorrectModifier.sol @@ -0,0 +1,152 @@ +//@compile-flags: --only-lint incorrect-modifier + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +contract IncorrectModifier { + bool enabled; + bool paused; + + modifier conditionalPlaceholder() { //~WARN: modifier can finish without executing the modified function + if (enabled) { + _; + } + } + + modifier returnsBeforePlaceholder() { //~WARN: modifier can finish without executing the modified function + if (paused) { + return; + } + _; + } + + modifier nestedConditionalPlaceholder() { //~WARN: modifier can finish without executing the modified function + if (enabled) { + if (!paused) { + _; + } + } else { + revert("disabled"); + } + } + + modifier loopOnlyPlaceholder() { //~WARN: modifier can finish without executing the modified function + while (enabled) { + _; + } + } + + modifier alwaysExecutes() { + _; + } + + modifier revertsOrExecutes() { + if (!enabled) { + revert("disabled"); + } + _; + } + + modifier conditionalElseReverts() { + if (enabled) { + _; + } else { + revert("disabled"); + } + } + + modifier requireFalseOrExecutes() { + if (paused) { + require(false, "disabled"); + return; + } + _; + } + + modifier assertFalseOrExecutes() { + if (paused) { + assert(false); + return; + } + _; + } + + modifier afterPlaceholderIsIrrelevant() { + _; + if (paused) { + return; + } + } + + // A `do/while` body always runs at least once, so `_` is always reached. + modifier doWhileAlwaysRuns() { + do { + _; + } while (enabled); + } + + // A loop before an unconditional placeholder still always reaches `_`. + modifier loopBeforePlaceholder() { + while (enabled) { + enabled = false; + } + _; + } + + modifier forLoopBeforePlaceholder() { + for (uint256 i = 0; i < 3; i++) { + enabled = false; + } + _; + } + + // Every try/catch path reaches `_` or reverts. + modifier tryCatchCovered() { + try this.poke() { + _; + } catch { + revert("failed"); + } + } + + // A `break` that skips `_` with no later placeholder is still flagged. + modifier breakSkipsPlaceholder() { //~WARN: modifier can finish without executing the modified function + while (enabled) { + if (paused) { + break; + } + _; + } + } + + // A `do/while` body runs once, but a `continue` reaches the trailing condition, which can then + // exit the loop before `_`. + modifier doWhileContinueSkipsPlaceholder() { //~WARN: modifier can finish without executing the modified function + do { + if (paused) { + continue; + } + _; + } while (enabled); + } + + // Assembly that always reverts before `_` reaches no normal exit, so it is not flagged. + modifier yulRevertOrExecutes() { + if (!enabled) { + assembly { + revert(0, 0) + } + } + _; + } + + // A `return(...)` in assembly halts successfully without running `_`, so it is flagged. + modifier yulReturnBeforePlaceholder() { //~WARN: modifier can finish without executing the modified function + assembly { + return(0, 0) + } + _; + } + + function poke() external {} +} diff --git a/crates/lint/testdata/IncorrectModifier.stderr b/crates/lint/testdata/IncorrectModifier.stderr new file mode 100644 index 0000000000000..8f9555f6a93cb --- /dev/null +++ b/crates/lint/testdata/IncorrectModifier.stderr @@ -0,0 +1,88 @@ +warning[incorrect-modifier]: modifier can finish without executing the modified function + ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC + │ +LL │ ┏ modifier conditionalPlaceholder() { +LL │ ┃ if (enabled) { +LL │ ┃ _; +LL │ ┃ } +LL │ ┃ } + │ ┗━━━━━┛ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier + +warning[incorrect-modifier]: modifier can finish without executing the modified function + ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC + │ +LL │ ┏ modifier returnsBeforePlaceholder() { +LL │ ┃ if (paused) { +LL │ ┃ return; + ‡ ┃ +LL │ ┃ } + │ ┗━━━━━┛ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier + +warning[incorrect-modifier]: modifier can finish without executing the modified function + ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC + │ +LL │ ┏ modifier nestedConditionalPlaceholder() { +LL │ ┃ if (enabled) { +LL │ ┃ if (!paused) { +LL │ ┃ _; + ‡ ┃ +LL │ ┃ } + │ ┗━━━━━┛ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier + +warning[incorrect-modifier]: modifier can finish without executing the modified function + ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC + │ +LL │ ┏ modifier loopOnlyPlaceholder() { +LL │ ┃ while (enabled) { +LL │ ┃ _; +LL │ ┃ } +LL │ ┃ } + │ ┗━━━━━┛ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier + +warning[incorrect-modifier]: modifier can finish without executing the modified function + ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC + │ +LL │ ┏ modifier breakSkipsPlaceholder() { +LL │ ┃ while (enabled) { +LL │ ┃ if (paused) { +LL │ ┃ break; + ‡ ┃ +LL │ ┃ } + │ ┗━━━━━┛ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier + +warning[incorrect-modifier]: modifier can finish without executing the modified function + ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC + │ +LL │ ┏ modifier doWhileContinueSkipsPlaceholder() { +LL │ ┃ do { +LL │ ┃ if (paused) { +LL │ ┃ continue; + ‡ ┃ +LL │ ┃ } while (enabled); +LL │ ┃ } + │ ┗━━━━━┛ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier + +warning[incorrect-modifier]: modifier can finish without executing the modified function + ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC + │ +LL │ ┏ modifier yulReturnBeforePlaceholder() { +LL │ ┃ assembly { +LL │ ┃ return(0, 0) + ‡ ┃ +LL │ ┃ } + │ ┗━━━━━┛ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier + diff --git a/crates/script/src/broadcast.rs b/crates/script/src/broadcast.rs index dc1aa5f166672..0bdb4ff8e7cd5 100644 --- a/crates/script/src/broadcast.rs +++ b/crates/script/src/broadcast.rs @@ -36,7 +36,7 @@ use foundry_common::{ shell, tempo::{ KeyEntry, KeysFile, TempoSponsor, WALLET_KEYS_PATH, decode_key_authorization, - maybe_print_fee_token, tempo_home, + maybe_print_fee_token, resolve_and_set_fee_token, tempo_home, }, }; use foundry_config::Config; @@ -107,6 +107,7 @@ where /// 1. Nonce synchronization: Waits for the provider's nonce to catch up to the expected /// transaction nonce when doing sequential broadcast /// 2. Gas estimation: Re-estimates gas right before broadcasting for chains that require it + #[allow(clippy::too_many_arguments)] pub async fn prepare( &mut self, provider: &RootProvider, @@ -115,6 +116,7 @@ where estimate_via_rpc: bool, estimate_multiplier: u64, tempo_sponsor: Option<&TempoSponsor>, + chain: Option, ) -> Result<()> { let (tx, access_key_authorization) = match self { Self::Raw(tx, _) | Self::Unlocked(tx) | Self::Browser(tx, _) => (tx, None), @@ -173,6 +175,13 @@ where .await?; } + let fee_token = if let Some(sponsor) = tempo_sponsor { + sponsor.resolve_and_set_fee_token(Some(provider), chain, tx).await?; + None + } else { + resolve_and_set_fee_token(Some(provider), chain, tx, tx.from()).await? + }; + // Chains which use `eth_estimateGas` are being sent sequentially and require their // gas to be re-estimated right before broadcasting. if !is_fixed_gas_limit && estimate_via_rpc { @@ -182,8 +191,9 @@ where if let Some(sponsor) = tempo_sponsor { let from = tx.from().expect("no sender"); sponsor.attach_and_print::(tx, from).await?; + } else { + maybe_print_fee_token(Some(provider), fee_token).await?; } - maybe_print_fee_token(Some(provider), tx.fee_token()).await?; Ok(()) } @@ -245,6 +255,7 @@ where /// /// This is a convenience method that combines [`prepare`](Self::prepare) and /// [`send`](Self::send) into a single call. + #[allow(clippy::too_many_arguments)] pub async fn prepare_and_send( mut self, provider: Arc>, @@ -253,6 +264,7 @@ where estimate_via_rpc: bool, estimate_multiplier: u64, tempo_sponsor: Option<&TempoSponsor>, + chain: Option, ) -> Result { self.prepare( &provider, @@ -261,6 +273,7 @@ where estimate_via_rpc, estimate_multiplier, tempo_sponsor, + chain, ) .await?; @@ -630,52 +643,51 @@ impl BundledState { // Iterate through transactions, matching the `from` field with the associated // wallet. Then send the transaction. Panics if we find a unknown `from` - let transactions = sequence - .transactions - .iter() - .skip(already_broadcasted) - .map(|tx_with_metadata| { - let is_fixed_gas_limit = tx_with_metadata.is_fixed_gas_limit; - - let kind = match tx_with_metadata.tx().clone() { - TransactionMaybeSigned::Signed { tx, .. } => { - if tempo_sponsor.is_some() { - eyre::bail!( - "cannot attach Tempo sponsor signature to an already signed script transaction" - ); - } - SendTransactionKind::Signed(tx) + let sequence_chain = sequence.chain; + let mut transactions = Vec::with_capacity( + sequence.transactions.len().saturating_sub(already_broadcasted), + ); + for tx_with_metadata in sequence.transactions.iter().skip(already_broadcasted) { + let is_fixed_gas_limit = tx_with_metadata.is_fixed_gas_limit; + + let kind = match tx_with_metadata.tx().clone() { + TransactionMaybeSigned::Signed { tx, .. } => { + if tempo_sponsor.is_some() { + eyre::bail!( + "cannot attach Tempo sponsor signature to an already signed script transaction" + ); } - TransactionMaybeSigned::Unsigned(mut tx) => { - let from = tx.from().expect("No sender for onchain transaction!"); + SendTransactionKind::Signed(tx) + } + TransactionMaybeSigned::Unsigned(mut tx) => { + let from = tx.from().expect("No sender for onchain transaction!"); - tx.set_chain_id(sequence.chain); + tx.set_chain_id(sequence_chain); - // Set TxKind::Create explicitly to satisfy `check_reqd_fields` in - // alloy - if tx.kind().is_none() { - tx.set_create(); - } + // Set TxKind::Create explicitly to satisfy `check_reqd_fields` in + // alloy + if tx.kind().is_none() { + tx.set_create(); + } - if let Some(gas_price) = gas_price { - tx.set_gas_price(gas_price); - } else { - let eip1559_fees = eip1559_fees.expect("was set above"); - tx.set_max_priority_fee_per_gas( - eip1559_fees.max_priority_fee_per_gas, - ); - tx.set_max_fee_per_gas(eip1559_fees.max_fee_per_gas); - } + if let Some(gas_price) = gas_price { + tx.set_gas_price(gas_price); + } else { + let eip1559_fees = eip1559_fees.expect("was set above"); + tx.set_max_priority_fee_per_gas( + eip1559_fees.max_priority_fee_per_gas, + ); + tx.set_max_fee_per_gas(eip1559_fees.max_fee_per_gas); + } - self.script_config.tempo.apply::(&mut tx, None); + self.script_config.tempo.apply::(&mut tx, None); - send_kind.for_sender(sequence.chain, &from, tx)? - } - }; + send_kind.for_sender(sequence_chain, &from, tx)? + } + }; - Ok((kind, is_fixed_gas_limit)) - }) - .collect::>>()?; + transactions.push((kind, is_fixed_gas_limit)); + } let estimate_via_rpc = has_different_gas_calc(sequence.chain) || self.script_config.evm_opts.networks.is_tempo() @@ -695,6 +707,7 @@ impl BundledState { // cannot handle more than that. let batch_size = if sequential_broadcast { 1 } else { 100 }; let mut index = already_broadcasted; + let sequence_chain = sequence.chain; for (batch_number, batch) in transactions.chunks(batch_size).enumerate() { seq_progress.inner.write().set_status(&format!( @@ -718,6 +731,7 @@ impl BundledState { estimate_via_rpc, self.args.gas_estimate_multiplier, tempo_sponsor.as_deref(), + Some(sequence_chain.into()), ) .await; (res, kind, *is_fixed_gas_limit, 0, None) @@ -763,6 +777,7 @@ impl BundledState { estimate_via_rpc, self.args.gas_estimate_multiplier, tempo_sponsor.as_deref(), + Some(sequence_chain.into()), ) .await; ( @@ -1238,6 +1253,24 @@ impl BundledState { ..Default::default() }; self.script_config.tempo.apply::(&mut batch_tx, None); + let fee_token = if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + Some(provider.as_ref()), + Some(Chain::from_named(NamedChain::Tempo)), + &mut batch_tx, + ) + .await?; + None + } else { + resolve_and_set_fee_token( + Some(provider.as_ref()), + Some(Chain::from_named(NamedChain::Tempo)), + &mut batch_tx, + Some(sender), + ) + .await? + }; if let BatchSigner::TempoKeychain(_, ak) = &batch_signer { batch_tx.key_id = Some(ak.key_address); @@ -1258,8 +1291,9 @@ impl BundledState { if let Some(sponsor) = &tempo_sponsor { sponsor.attach_and_print::(&mut batch_tx, sender).await?; + } else { + maybe_print_fee_token(Some(provider.as_ref()), fee_token).await?; } - maybe_print_fee_token(Some(provider.as_ref()), batch_tx.fee_token()).await?; // Sign and send. let tx_hash = match batch_signer { @@ -1576,7 +1610,18 @@ mod tests { let provider = RootProvider::::new_http("http://localhost:8545".parse().unwrap()); - sender.prepare(&provider, false, true, false, 100, None).await.unwrap(); + sender + .prepare( + &provider, + false, + true, + false, + 100, + None, + Some(Chain::from_named(NamedChain::Mainnet)), + ) + .await + .unwrap(); match sender { SendTransactionKind::AccessKey(tx, _, _) => { diff --git a/crates/script/src/execute.rs b/crates/script/src/execute.rs index 52e334a1c62f4..a6807f2af14a0 100644 --- a/crates/script/src/execute.rs +++ b/crates/script/src/execute.rs @@ -168,6 +168,7 @@ impl PreExecutionState { setup_result.logs.extend(script_result.logs); setup_result.traces.extend(script_result.traces); setup_result.labeled_addresses.extend(script_result.labeled_addresses); + setup_result.debug_bytecodes.extend(script_result.debug_bytecodes); setup_result.returned = script_result.returned; setup_result.exit_reason = script_result.exit_reason; setup_result.breakpoints = script_result.breakpoints; @@ -359,9 +360,15 @@ impl ExecutedState { ) .build(); - let mut identifier = TraceIdentifiers::new() - .with_local(known_contracts) - .with_external(&self.script_config.config, chain_id)?; + let use_debug_bytecodes = + self.args.debug && !self.execution_result.debug_bytecodes.is_empty(); + let mut identifier = if use_debug_bytecodes { + TraceIdentifiers::new() + .with_local_and_bytecodes(known_contracts, &self.execution_result.debug_bytecodes) + } else { + TraceIdentifiers::new().with_local(known_contracts) + } + .with_external(&self.script_config.config, chain_id)?; for (_, trace) in &self.execution_result.traces { decoder.identify(trace, &mut identifier); diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 3aa3570a767a6..8a306c1b730d1 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -13,7 +13,6 @@ extern crate foundry_common; extern crate tracing; use crate::{broadcast::BundledState, runner::ScriptRunner}; -use alloy_chains::{Chain, NamedChain}; use alloy_json_abi::{Function, JsonAbi}; use alloy_network::Network; use alloy_primitives::{ @@ -37,7 +36,6 @@ use foundry_common::{ abi::{encode_function_args, get_func}, compile::ContractSizeLimits, shell, - tempo::resolve_fee_token, }; use foundry_compilers::ArtifactId; use foundry_config::{ @@ -314,10 +312,6 @@ impl ScriptArgs { tempo.resolve_expires(); - // Resolve the fee token: default only when the active EVM network is Tempo. - let chain = evm_opts.networks.is_tempo().then(|| Chain::from_named(NamedChain::Tempo)); - tempo.fee_token = resolve_fee_token(chain, tempo.fee_token); - let script_config = ScriptConfig::new(config, evm_opts, args.batch, tempo).await?; Ok(PreprocessedState { args, script_config, script_wallets, browser_wallet }) } @@ -684,6 +678,8 @@ pub struct ScriptResult { pub gas_used: u64, pub labeled_addresses: AddressHashMap, #[serde(skip)] + pub debug_bytecodes: AddressHashMap, + #[serde(skip)] pub transactions: Option>, pub returned: Bytes, #[serde(skip)] @@ -701,6 +697,7 @@ impl Default for ScriptResult { traces: Default::default(), gas_used: Default::default(), labeled_addresses: Default::default(), + debug_bytecodes: Default::default(), transactions: Default::default(), returned: Default::default(), exit_reason: Default::default(), @@ -877,13 +874,15 @@ impl ScriptConfig { // (e.g. script deployment, setUp) use the correct fee token for Tempo networks. tx_env.set_fee_token(self.tempo.fee_token); - Ok(ScriptRunner::new(builder.build(evm_env, tx_env, db), self.evm_opts.clone())) + Ok(ScriptRunner::new(builder.build(evm_env, tx_env, db), self.evm_opts.clone()) + .with_debug_bytecodes(debug)) } } #[cfg(test)] mod tests { use super::*; + use alloy_chains::NamedChain; use alloy_network::Ethereum; use alloy_primitives::{B256, address}; use foundry_cli::opts::TEMPO_SESSION_ID_ENV; diff --git a/crates/script/src/runner.rs b/crates/script/src/runner.rs index 22b13e9b4300a..902ae799c943d 100644 --- a/crates/script/src/runner.rs +++ b/crates/script/src/runner.rs @@ -3,7 +3,7 @@ use crate::build::ScriptPredeployLibraries; use alloy_eips::eip7702::SignedAuthorization; use alloy_evm::revm::context::Transaction; use alloy_network::TransactionBuilder; -use alloy_primitives::{Address, Bytes, U256}; +use alloy_primitives::{Address, Bytes, U256, map::AddressHashMap}; use eyre::Result; use foundry_cheatcodes::BroadcastableTransaction; use foundry_common::TransactionMaybeSigned; @@ -26,11 +26,34 @@ use std::collections::VecDeque; pub struct ScriptRunner { pub executor: Executor, pub evm_opts: EvmOpts, + collect_debug_bytecodes: bool, } impl ScriptRunner { pub const fn new(executor: Executor, evm_opts: EvmOpts) -> Self { - Self { executor, evm_opts } + Self { executor, evm_opts, collect_debug_bytecodes: false } + } + + pub const fn with_debug_bytecodes(mut self, collect_debug_bytecodes: bool) -> Self { + self.collect_debug_bytecodes = collect_debug_bytecodes; + self + } + + fn maybe_debug_bytecodes( + &self, + debug_bytecodes: AddressHashMap, + ) -> AddressHashMap { + if self.collect_debug_bytecodes { debug_bytecodes } else { Default::default() } + } + + fn extend_debug_bytecodes( + &self, + target: &mut AddressHashMap, + debug_bytecodes: AddressHashMap, + ) { + if self.collect_debug_bytecodes { + target.extend(debug_bytecodes); + } } /// Deploys the libraries and broadcast contract. Calls setUp method if requested. @@ -65,18 +88,25 @@ impl ScriptRunner { let mut library_transactions = VecDeque::new(); let mut traces = Traces::default(); + let mut debug_bytecodes: AddressHashMap = Default::default(); // Deploy libraries match libraries { ScriptPredeployLibraries::Default(libraries) => { for code in libraries { - let result = self + let RawCallResult { + traces: deploy_traces, + debug_bytecodes: deploy_debug_bytecodes, + .. + } = self .executor .deploy(self.evm_opts.sender, code.clone(), U256::ZERO, None) .expect("couldn't deploy library") .raw; - if let Some(deploy_traces) = result.traces { + self.extend_debug_bytecodes(&mut debug_bytecodes, deploy_debug_bytecodes); + + if let Some(deploy_traces) = deploy_traces { traces.push((TraceKind::Deployment, deploy_traces)); } @@ -102,7 +132,11 @@ impl ScriptRunner { continue; } let calldata = [salt.as_ref(), library.as_ref()].concat(); - let result = self + let RawCallResult { + traces: deploy_traces, + debug_bytecodes: deploy_debug_bytecodes, + .. + } = self .executor .transact_raw( self.evm_opts.sender, @@ -112,7 +146,9 @@ impl ScriptRunner { ) .expect("couldn't deploy library"); - if let Some(deploy_traces) = result.traces { + self.extend_debug_bytecodes(&mut debug_bytecodes, deploy_debug_bytecodes); + + if let Some(deploy_traces) = deploy_traces { traces.push((TraceKind::Deployment, deploy_traces)); } @@ -159,7 +195,13 @@ impl ScriptRunner { // Deploy an instance of the contract let DeployResult { address, - raw: RawCallResult { mut logs, traces: constructor_traces, .. }, + raw: + RawCallResult { + mut logs, + traces: constructor_traces, + debug_bytecodes: constructor_debug_bytecodes, + .. + }, } = self .executor .deploy(CALLER, code, U256::ZERO, None) @@ -175,6 +217,7 @@ impl ScriptRunner { } traces.extend(constructor_traces.map(|traces| (TraceKind::Deployment, traces))); + self.extend_debug_bytecodes(&mut debug_bytecodes, constructor_debug_bytecodes); // Optionally call the `setUp` function let (success, gas_used, labeled_addresses, transactions) = if setup { @@ -185,11 +228,13 @@ impl ScriptRunner { labels, logs: setup_logs, gas_used, + debug_bytecodes: setup_debug_bytecodes, transactions: setup_transactions, .. }) => { traces.extend(setup_traces.map(|traces| (TraceKind::Setup, traces))); logs.extend_from_slice(&setup_logs); + self.extend_debug_bytecodes(&mut debug_bytecodes, setup_debug_bytecodes); if let Some(txs) = setup_transactions { library_transactions.extend(txs); @@ -204,11 +249,13 @@ impl ScriptRunner { labels, logs: setup_logs, gas_used, + debug_bytecodes: setup_debug_bytecodes, transactions, .. } = err.raw; traces.extend(setup_traces.map(|traces| (TraceKind::Setup, traces))); logs.extend_from_slice(&setup_logs); + self.extend_debug_bytecodes(&mut debug_bytecodes, setup_debug_bytecodes); if let Some(txs) = transactions { library_transactions.extend(txs); @@ -230,6 +277,7 @@ impl ScriptRunner { success, gas_used, labeled_addresses, + debug_bytecodes: self.maybe_debug_bytecodes(debug_bytecodes), transactions, logs, traces, @@ -273,7 +321,10 @@ impl ScriptRunner { value.unwrap_or(U256::ZERO), None, ); - let (address, RawCallResult { gas_used, logs, traces, exit_reason, .. }) = match res { + let ( + address, + RawCallResult { gas_used, logs, traces, debug_bytecodes, exit_reason, .. }, + ) = match res { Ok(DeployResult { address, raw }) => (address, raw), Err(EvmError::Execution(err)) => { let ExecutionErr { raw, reason } = *err; @@ -288,6 +339,7 @@ impl ScriptRunner { success: address != Address::ZERO, gas_used, logs, + debug_bytecodes: self.maybe_debug_bytecodes(debug_bytecodes), // Manually adjust gas for the trace to add back the stipend/real used gas traces: traces .map(|traces| vec![(TraceKind::Execution, traces)]) @@ -348,15 +400,25 @@ impl ScriptRunner { } let RawCallResult { - result, reverted, logs, traces, labels, transactions, exit_reason, .. + result, + reverted, + logs, + traces, + labels, + transactions, + debug_bytecodes, + exit_reason, + cheatcodes, + .. } = res; - let breakpoints = res.cheatcodes.map(|cheats| cheats.breakpoints).unwrap_or_default(); + let breakpoints = cheatcodes.map(|cheats| cheats.breakpoints).unwrap_or_default(); Ok(ScriptResult { returned: result, success: !reverted, gas_used, logs, + debug_bytecodes: self.maybe_debug_bytecodes(debug_bytecodes), traces: traces .map(|traces| { // Manually adjust gas for the trace to add back the stipend/real used gas diff --git a/crates/verify/src/sourcify.rs b/crates/verify/src/sourcify.rs index 19931da45b528..27ec7c5c19cbe 100644 --- a/crates/verify/src/sourcify.rs +++ b/crates/verify/src/sourcify.rs @@ -100,7 +100,7 @@ impl VerificationProvider for SourcifyVerificationProvider { .await?; if let Some(resp) = resp { - let job_url = Self::get_job_status_url( + let job_url = Self::get_job_ui_url( args.verifier.verifier_url.as_deref(), resp.verification_id.clone(), ); @@ -208,6 +208,11 @@ impl SourcifyVerificationProvider { format!("{base_url}v2/verify/{job_id}") } + fn get_job_ui_url(verifier_url: Option<&str>, job_id: String) -> String { + let base_url = Self::get_base_url(verifier_url); + format!("{base_url}verify-ui/jobs/{job_id}") + } + fn get_lookup_url( verifier_url: Option<&str>, chain_id: u64,