Skip to main content

chia_sdk_driver/layers/
p2_eip712_message_layer.rs

1use chia_protocol::Bytes32;
2use chia_sdk_types::puzzles::{
3    Eip712PrefixAndDomainSeparator, P2_EIP712_MESSAGE_PUZZLE_HASH, P2Eip712MessageArgs,
4    P2Eip712MessageSolution,
5};
6use chia_secp::{K1PublicKey, K1Signature};
7use clvm_traits::FromClvm;
8use clvmr::{Allocator, NodePtr};
9use sha3::{Digest, Keccak256};
10
11use crate::{DriverError, Layer, Puzzle, Spend, SpendContext};
12
13/// The CHIP-0037 EIP-712 message [`Layer`].
14///
15/// Allows a coin to be controlled by an Ethereum-style wallet that signs an
16/// EIP-712 typed-data message of type `ChiaCoinSpend(bytes32 coin_id, bytes32
17/// delegated_puzzle_hash)`. The puzzle reconstructs the EIP-712 digest from
18/// the signed envelope (`\x19\x01 || domainSeparator || keccak256(typeHash ||
19/// coin_id || delegated_puzzle_hash)`) inside a `softfork` guard so the spend
20/// is bound to this exact coin and chosen `delegated_puzzle`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct P2Eip712MessageLayer {
23    pub prefix_and_domain_separator: Eip712PrefixAndDomainSeparator,
24    pub public_key: K1PublicKey,
25}
26
27impl P2Eip712MessageLayer {
28    pub fn new(
29        public_key: K1PublicKey,
30        prefix_and_domain_separator: Eip712PrefixAndDomainSeparator,
31    ) -> Self {
32        Self {
33            prefix_and_domain_separator,
34            public_key,
35        }
36    }
37
38    /// Convenience constructor that derives the prefix-and-domain-separator
39    /// from a network's genesis challenge.
40    pub fn from_genesis_challenge(public_key: K1PublicKey, genesis_challenge: Bytes32) -> Self {
41        Self {
42            prefix_and_domain_separator: Self::prefix_and_domain_separator(genesis_challenge),
43            public_key,
44        }
45    }
46
47    /// Compute the EIP-712 domain separator for the given Chia network.
48    ///
49    /// Domain matches the schema mandated by CHIP-0037:
50    /// `{ name: "Chia Coin Spend", version: "1", salt: <genesis_challenge> }`.
51    pub fn domain_separator(genesis_challenge: Bytes32) -> Bytes32 {
52        let type_hash = Keccak256::digest(b"EIP712Domain(string name,string version,bytes32 salt)");
53
54        let mut to_hash = Vec::new();
55        to_hash.extend_from_slice(&type_hash);
56        to_hash.extend_from_slice(&Keccak256::digest(b"Chia Coin Spend"));
57        to_hash.extend_from_slice(&Keccak256::digest(b"1"));
58        to_hash.extend_from_slice(&genesis_challenge);
59
60        Bytes32::new(Keccak256::digest(&to_hash).into())
61    }
62
63    /// Compute the 34-byte concatenation of the EIP-712 prefix (`\x19\x01`)
64    /// and the domain separator for a given network.
65    pub fn prefix_and_domain_separator(
66        genesis_challenge: Bytes32,
67    ) -> Eip712PrefixAndDomainSeparator {
68        let mut bytes = [0u8; 34];
69        bytes[0] = 0x19;
70        bytes[1] = 0x01;
71        bytes[2..].copy_from_slice(&Self::domain_separator(genesis_challenge));
72        bytes.into()
73    }
74
75    /// Keccak-256 hash of the canonical CHIP-0037 EIP-712 type signature.
76    pub fn type_hash() -> Bytes32 {
77        Bytes32::new(
78            Keccak256::digest(b"ChiaCoinSpend(bytes32 coin_id,bytes32 delegated_puzzle_hash)")
79                .into(),
80        )
81    }
82
83    /// Compute the 32-byte EIP-712 digest the off-chain wallet must sign.
84    ///
85    /// `digest = keccak256(prefix_and_domain || keccak256(type_hash || coin_id
86    /// || delegated_puzzle_hash))`
87    pub fn hash_to_sign(&self, coin_id: Bytes32, delegated_puzzle_hash: Bytes32) -> Bytes32 {
88        let mut to_hash = Vec::with_capacity(96);
89        to_hash.extend_from_slice(&Self::type_hash());
90        to_hash.extend_from_slice(&coin_id);
91        to_hash.extend_from_slice(&delegated_puzzle_hash);
92        let message_hash = Keccak256::digest(&to_hash);
93
94        let mut to_hash = Vec::with_capacity(34 + 32);
95        to_hash.extend_from_slice(&self.prefix_and_domain_separator);
96        to_hash.extend_from_slice(&message_hash);
97
98        Bytes32::new(Keccak256::digest(&to_hash).into())
99    }
100
101    /// Construct a `Spend` for a coin locked by this layer using a previously
102    /// obtained signature over `hash_to_sign(coin_id, tree_hash(delegated))`.
103    pub fn spend(
104        &self,
105        ctx: &mut SpendContext,
106        my_id: Bytes32,
107        signature: K1Signature,
108        delegated_spend: Spend,
109    ) -> Result<Spend, DriverError> {
110        let signed_hash = self.hash_to_sign(my_id, ctx.tree_hash(delegated_spend.puzzle).into());
111        self.construct_spend(
112            ctx,
113            P2Eip712MessageSolution {
114                my_id,
115                signed_hash,
116                signature,
117                delegated_puzzle: delegated_spend.puzzle,
118                delegated_solution: delegated_spend.solution,
119            },
120        )
121    }
122}
123
124impl Layer for P2Eip712MessageLayer {
125    type Solution = P2Eip712MessageSolution<NodePtr, NodePtr>;
126
127    fn parse_puzzle(allocator: &Allocator, puzzle: Puzzle) -> Result<Option<Self>, DriverError> {
128        let Some(puzzle) = puzzle.as_curried() else {
129            return Ok(None);
130        };
131
132        if puzzle.mod_hash != P2_EIP712_MESSAGE_PUZZLE_HASH {
133            return Ok(None);
134        }
135
136        let args = P2Eip712MessageArgs::from_clvm(allocator, puzzle.args)?;
137
138        if args.type_hash != Self::type_hash() {
139            return Ok(None);
140        }
141
142        Ok(Some(Self {
143            prefix_and_domain_separator: args.prefix_and_domain_separator,
144            public_key: args.public_key,
145        }))
146    }
147
148    fn parse_solution(
149        allocator: &Allocator,
150        solution: NodePtr,
151    ) -> Result<Self::Solution, DriverError> {
152        Ok(P2Eip712MessageSolution::from_clvm(allocator, solution)?)
153    }
154
155    fn construct_puzzle(&self, ctx: &mut SpendContext) -> Result<NodePtr, DriverError> {
156        ctx.curry(P2Eip712MessageArgs {
157            prefix_and_domain_separator: self.prefix_and_domain_separator,
158            type_hash: Self::type_hash(),
159            public_key: self.public_key,
160        })
161    }
162
163    fn construct_solution(
164        &self,
165        ctx: &mut SpendContext,
166        solution: Self::Solution,
167    ) -> Result<NodePtr, DriverError> {
168        ctx.alloc(&solution)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    use chia_consensus::consensus_constants::TEST_CONSTANTS;
177    use chia_protocol::Bytes;
178    use chia_sdk_test::Simulator;
179    use chia_sdk_types::Conditions;
180    use chia_secp::K1SecretKey;
181    use clvm_traits::{ToClvm, clvm_quote};
182    use clvm_utils::ToTreeHash;
183    use clvmr::chia_dialect::ENABLE_KECCAK_OPS_OUTSIDE_GUARD;
184    use clvmr::reduction::Reduction;
185    use clvmr::serde::node_from_bytes;
186    use hex_literal::hex;
187    use rand::{Rng, SeedableRng};
188    use rand_chacha::ChaCha8Rng;
189    use rstest::rstest;
190
191    fn k1_pair(seed: u64) -> (K1SecretKey, K1PublicKey) {
192        let mut rng = ChaCha8Rng::seed_from_u64(seed);
193        let sk = K1SecretKey::from_bytes(&rng.random()).unwrap();
194        let pk = sk.public_key();
195        (sk, pk)
196    }
197
198    #[test]
199    fn test_type_hash() {
200        assert_eq!(
201            P2Eip712MessageLayer::type_hash(),
202            Bytes32::new(hex!(
203                "72930978f119c79f9de7a13bd50c9b3261132d7b4819bdf0d3ca4d4c37ade070"
204            ))
205        );
206    }
207
208    #[test]
209    fn test_domain_separator_is_deterministic() {
210        // Two invocations with the same genesis challenge must produce the
211        // same domain separator (the value itself depends on which network
212        // `TEST_CONSTANTS` currently models).
213        let a = P2Eip712MessageLayer::domain_separator(TEST_CONSTANTS.genesis_challenge);
214        let b = P2Eip712MessageLayer::domain_separator(TEST_CONSTANTS.genesis_challenge);
215        assert_eq!(a, b);
216    }
217
218    #[test]
219    fn test_domain_separator_mainnet_fixture() {
220        // Mainnet genesis challenge per chia-blockchain/initial-config.yaml.
221        let mainnet_genesis = Bytes32::new(hex!(
222            "ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb"
223        ));
224        // Pinned digest produced by the canonical EIP-712 algorithm
225        // (`keccak256(typeHash || keccak256("Chia Coin Spend") ||
226        // keccak256("1") || mainnet_genesis)`); regenerate this if upstream
227        // ever changes the EIP-712 schema.
228        assert_eq!(
229            P2Eip712MessageLayer::domain_separator(mainnet_genesis),
230            Bytes32::new(hex!(
231                "38d765d3bce341eed11f92fc1311d575f34cc9ee0fc0e1f03820e11aebf6b5b2"
232            ))
233        );
234    }
235
236    /// Pin the cost of the keccak256-reconstruction sub-puzzle that runs inside
237    /// the `softfork` guard. CHIP-0037 fixes this at exactly 2605 cost.
238    #[test]
239    fn test_softfork_cost() -> anyhow::Result<()> {
240        let mut ctx = SpendContext::new();
241        // Equivalent CLVM source:
242        //   (mod (PREFIX_AND_DOMAIN_SEPARATOR TYPE_HASH my_id delegated_puzzle_hash signed_hash)
243        //     (if (= (keccak256 PREFIX_AND_DOMAIN_SEPARATOR
244        //                       (keccak256 TYPE_HASH my_id delegated_puzzle_hash))
245        //            signed_hash) () (x)))
246        let puzzle_bytes =
247            hex!("ff02ffff03ffff09ffff3eff02ffff3eff05ff0bff178080ff2f80ff80ffff01ff088080ff0180");
248        let puzzle_ptr = node_from_bytes(&mut ctx, puzzle_bytes.as_slice())?;
249        let solution_ptr = vec![
250            // PREFIX_AND_DOMAIN_SEPARATOR (testnet/mainnet-independent fixture)
251            Bytes::new(
252                hex!("1901098ccd7d09a29365582c3f7590712bc2c2eb8503586f8a4c628c61c73ffbe4aa")
253                    .to_vec(),
254            ),
255            // TYPE_HASH
256            Bytes::new(
257                hex!("72930978f119c79f9de7a13bd50c9b3261132d7b4819bdf0d3ca4d4c37ade070").to_vec(),
258            ),
259            // my_id
260            Bytes::new(
261                hex!("5c777c45fd52a17a54e420742cadc56172847d9a106ff0ff8af38ef757d84829").to_vec(),
262            ),
263            // delegated_puzzle_hash
264            Bytes::new(
265                hex!("d842dfa1453a130a8be66bc32708a2d1884662d7daaa4aae530be3259fa6712f").to_vec(),
266            ),
267            // signed_hash
268            Bytes::new(
269                hex!("9f61fdf6077c3eeb96eaa4dd450b11ba3ae17746a2c304388218137972c7ba4c").to_vec(),
270            ),
271        ]
272        .to_clvm(&mut *ctx)?;
273
274        let Reduction(cost, _) = clvmr::run_program(
275            &mut ctx,
276            &clvmr::ChiaDialect::new(ENABLE_KECCAK_OPS_OUTSIDE_GUARD),
277            puzzle_ptr,
278            solution_ptr,
279            11_000_000_000,
280        )?;
281
282        assert_eq!(cost, 2605);
283        Ok(())
284    }
285
286    #[rstest]
287    #[case::successful_spend(true)]
288    #[case::incorrect_signed_hash(false)]
289    fn test_p2_eip712_message(#[case] correct_signed_hash: bool) -> anyhow::Result<()> {
290        let (sk, pk) = k1_pair(0xC0DE_F00D);
291
292        let mut sim = Simulator::new();
293        let mut ctx = SpendContext::new();
294        let ctx = &mut ctx;
295
296        let layer =
297            P2Eip712MessageLayer::from_genesis_challenge(pk, TEST_CONSTANTS.genesis_challenge);
298        let coin_puzzle_reveal = layer.construct_puzzle(ctx)?;
299        let coin_puzzle_hash = ctx.tree_hash(coin_puzzle_reveal);
300
301        let coin = sim.new_coin(coin_puzzle_hash.into(), 1337);
302
303        let delegated_puzzle_ptr =
304            clvm_quote!(Conditions::new().reserve_fee(1337)).to_clvm(&mut **ctx)?;
305        let delegated_solution_ptr = ctx.nil();
306
307        // For the negative case we substitute a digest the wallet would never
308        // produce for this spend, so the on-chain reconstruction check fails.
309        let signed_hash: Bytes32 = if correct_signed_hash {
310            layer.hash_to_sign(coin.coin_id(), ctx.tree_hash(delegated_puzzle_ptr).into())
311        } else {
312            layer
313                .hash_to_sign(coin.coin_id(), ctx.tree_hash(delegated_puzzle_ptr).into())
314                .tree_hash()
315                .into()
316        };
317
318        let signed_hash_bytes: [u8; 32] = signed_hash.into();
319        let signature = sk.sign_prehashed(&signed_hash_bytes)?;
320
321        let coin_spend = layer.construct_coin_spend(
322            ctx,
323            coin,
324            P2Eip712MessageSolution {
325                my_id: coin.coin_id(),
326                signed_hash,
327                signature,
328                delegated_puzzle: delegated_puzzle_ptr,
329                delegated_solution: delegated_solution_ptr,
330            },
331        )?;
332
333        ctx.insert(coin_spend);
334
335        if correct_signed_hash {
336            sim.spend_coins(ctx.take(), &[])?;
337        } else {
338            let err = sim
339                .spend_coins(ctx.take(), &[])
340                .expect_err("spend should fail when signed_hash is wrong");
341            let msg = err.to_string();
342            assert!(
343                msg.contains("clvm raise") || msg.to_lowercase().contains("raise"),
344                "unexpected error: {msg}"
345            );
346        }
347
348        Ok(())
349    }
350}