Skip to main content

freenet_stdlib/contract_interface/
key.rs

1//! Contract key types and identifiers.
2//!
3//! This module provides the core types for identifying contracts:
4//! - `ContractInstanceId`: The hash of contract code and parameters (use for routing/lookup)
5//! - `ContractKey`: A complete key specification with code hash (use for storage/execution)
6
7use std::borrow::Borrow;
8use std::fmt::Display;
9use std::hash::{Hash, Hasher};
10use std::ops::Deref;
11use std::str::FromStr;
12
13use blake3::{traits::digest::Digest, Hasher as Blake3};
14use serde::{Deserialize, Serialize};
15use serde_with::serde_as;
16
17use crate::client_api::{TryFromFbs, WsApiError};
18use crate::code_hash::CodeHash;
19use crate::common_generated::common::ContractKey as FbsContractKey;
20use crate::parameters::Parameters;
21
22use super::code::ContractCode;
23use super::CONTRACT_KEY_SIZE;
24
25/// The key representing the hash of the contract executable code hash and a set of `parameters`.
26#[serde_as]
27#[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Hash)]
28#[cfg_attr(
29    any(feature = "testing", all(test, any(unix, windows))),
30    derive(arbitrary::Arbitrary)
31)]
32#[repr(transparent)]
33pub struct ContractInstanceId(#[serde_as(as = "[_; CONTRACT_KEY_SIZE]")] [u8; CONTRACT_KEY_SIZE]);
34
35impl ContractInstanceId {
36    pub fn from_params_and_code<'a>(
37        params: impl Borrow<Parameters<'a>>,
38        code: impl Borrow<ContractCode<'a>>,
39    ) -> Self {
40        generate_id(params.borrow(), code.borrow())
41    }
42
43    pub const fn new(key: [u8; CONTRACT_KEY_SIZE]) -> Self {
44        Self(key)
45    }
46
47    /// `Base58` string representation of the `contract id`.
48    pub fn encode(&self) -> String {
49        bs58::encode(self.0)
50            .with_alphabet(bs58::Alphabet::BITCOIN)
51            .into_string()
52    }
53
54    pub fn as_bytes(&self) -> &[u8] {
55        self.0.as_slice()
56    }
57
58    /// Parse a `ContractInstanceId` from its **base58 text** form — the inverse
59    /// of [`Self::encode`].
60    ///
61    /// The input is base58 *characters*, not the id's 32 raw bytes. It takes
62    /// `impl AsRef<[u8]>` only so that `&str`, `String` and `&[u8]` of base58
63    /// text all work; handing it a raw 32-byte id is a bug, and one that does
64    /// not look like a bug at the call site. Use
65    /// [`ContractInstanceId::new`] for raw bytes you already hold, or the
66    /// crate-internal `instance_id_from_fbs` for bytes off the wire.
67    ///
68    /// This was called `from_bytes`, documented as "build from the binary
69    /// representation". That name and doc caused four decode sites to feed it
70    /// raw wire bytes, which panicked on every well-formed request: a random
71    /// 32-byte id essentially never consists solely of base58 characters (the
72    /// alphabet is 58 of 256 byte values, so the odds are about 2e-21).
73    ///
74    /// Caveat, pre-existing and unchanged: `bs58`'s `onto` writes the decoded
75    /// bytes LEFT-aligned into the 32-byte buffer and reports how many it
76    /// wrote, which this discards. So base58 text that decodes to fewer than 32
77    /// bytes yields a zero-padded id rather than an error — `from_base58("1")`
78    /// is the all-zero id. Callers that accept untrusted text (freenet-core
79    /// parses URL path segments with this) get a well-formed but wrong id
80    /// rather than a rejection.
81    pub fn from_base58(bytes: impl AsRef<[u8]>) -> Result<Self, bs58::decode::Error> {
82        let mut spec = [0; CONTRACT_KEY_SIZE];
83        bs58::decode(bytes)
84            .with_alphabet(bs58::Alphabet::BITCOIN)
85            .onto(&mut spec)?;
86        Ok(Self(spec))
87    }
88
89    /// Renamed to [`Self::from_base58`], which says what it actually does.
90    ///
91    /// Kept as a delegating alias so the rename is not a breaking change.
92    // No `since`: the release this lands in is not decided here, and a guessed
93    // version is unfixable once published.
94    #[deprecated(
95        note = "renamed to `from_base58`: this parses base58 TEXT, not raw bytes. \
96                For a raw 32-byte id use `ContractInstanceId::new`."
97    )]
98    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self, bs58::decode::Error> {
99        Self::from_base58(bytes)
100    }
101}
102
103impl Deref for ContractInstanceId {
104    type Target = [u8; CONTRACT_KEY_SIZE];
105
106    fn deref(&self) -> &Self::Target {
107        &self.0
108    }
109}
110
111impl FromStr for ContractInstanceId {
112    type Err = bs58::decode::Error;
113
114    fn from_str(s: &str) -> Result<Self, Self::Err> {
115        ContractInstanceId::from_base58(s)
116    }
117}
118
119impl TryFrom<String> for ContractInstanceId {
120    type Error = bs58::decode::Error;
121
122    fn try_from(s: String) -> Result<Self, Self::Error> {
123        ContractInstanceId::from_base58(s)
124    }
125}
126
127impl Display for ContractInstanceId {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "{}", self.encode())
130    }
131}
132
133impl std::fmt::Debug for ContractInstanceId {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_tuple("ContractInstanceId")
136            .field(&self.encode())
137            .finish()
138    }
139}
140
141/// A complete key specification, that represents a cryptographic hash that identifies the contract.
142///
143/// This type always contains both the instance ID and the code hash.
144/// Use `ContractInstanceId` for operations that only need to identify the contract
145/// (routing, client requests), and `ContractKey` for operations that need the full
146/// specification (storage, execution).
147#[serde_as]
148#[derive(Debug, Eq, Copy, Clone, Serialize, Deserialize)]
149#[cfg_attr(
150    any(feature = "testing", all(test, any(unix, windows))),
151    derive(arbitrary::Arbitrary)
152)]
153pub struct ContractKey {
154    instance: ContractInstanceId,
155    code: CodeHash,
156}
157
158impl ContractKey {
159    pub fn from_params_and_code<'a>(
160        params: impl Borrow<Parameters<'a>>,
161        wasm_code: impl Borrow<ContractCode<'a>>,
162    ) -> Self {
163        let code = wasm_code.borrow();
164        let id = generate_id(params.borrow(), code);
165        let code_hash = *code.hash();
166        Self {
167            instance: id,
168            code: code_hash,
169        }
170    }
171
172    /// Gets the whole spec key hash.
173    pub fn as_bytes(&self) -> &[u8] {
174        self.instance.0.as_ref()
175    }
176
177    /// Returns the hash of the contract code.
178    pub fn code_hash(&self) -> &CodeHash {
179        &self.code
180    }
181
182    /// Returns the encoded hash of the contract code.
183    pub fn encoded_code_hash(&self) -> String {
184        bs58::encode(self.code.0)
185            .with_alphabet(bs58::Alphabet::BITCOIN)
186            .into_string()
187    }
188
189    /// Returns the contract key from the encoded hash of the contract code and the given
190    /// parameters.
191    pub fn from_params(
192        code_hash: impl Into<String>,
193        parameters: Parameters,
194    ) -> Result<Self, bs58::decode::Error> {
195        let mut code_key = [0; CONTRACT_KEY_SIZE];
196        bs58::decode(code_hash.into())
197            .with_alphabet(bs58::Alphabet::BITCOIN)
198            .onto(&mut code_key)?;
199
200        let mut hasher = Blake3::new();
201        hasher.update(code_key.as_slice());
202        hasher.update(parameters.as_ref());
203        let full_key_arr = hasher.finalize();
204
205        let mut spec = [0; CONTRACT_KEY_SIZE];
206        spec.copy_from_slice(&full_key_arr);
207        Ok(Self {
208            instance: ContractInstanceId(spec),
209            code: CodeHash(code_key),
210        })
211    }
212
213    /// Returns the `Base58` encoded string of the [`ContractInstanceId`](ContractInstanceId).
214    pub fn encoded_contract_id(&self) -> String {
215        self.instance.encode()
216    }
217
218    pub fn id(&self) -> &ContractInstanceId {
219        &self.instance
220    }
221
222    /// Constructs a ContractKey from a pre-computed instance ID and code hash.
223    ///
224    /// This is useful when the node needs to reconstruct a key from stored index data.
225    /// Callers must ensure the instance_id was correctly derived from the code_hash
226    /// and parameters, as this constructor does not verify consistency.
227    pub fn from_id_and_code(instance_id: ContractInstanceId, code_hash: CodeHash) -> Self {
228        Self {
229            instance: instance_id,
230            code: code_hash,
231        }
232    }
233}
234
235impl PartialEq for ContractKey {
236    fn eq(&self, other: &Self) -> bool {
237        self.instance == other.instance
238    }
239}
240
241impl std::hash::Hash for ContractKey {
242    fn hash<H: Hasher>(&self, state: &mut H) {
243        self.instance.0.hash(state);
244    }
245}
246
247impl From<ContractKey> for ContractInstanceId {
248    fn from(key: ContractKey) -> Self {
249        key.instance
250    }
251}
252
253impl Deref for ContractKey {
254    type Target = [u8; CONTRACT_KEY_SIZE];
255
256    fn deref(&self) -> &Self::Target {
257        &self.instance.0
258    }
259}
260
261impl std::fmt::Display for ContractKey {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        self.instance.fmt(f)
264    }
265}
266
267/// Error text for a `ContractKey` whose wire `code` field cannot be read as a
268/// contract code hash.
269///
270/// `observed` is the field's byte length, or `None` when the optional field was
271/// omitted entirely. Both cases share one message because they fail for the same
272/// reason and have the same remedy.
273///
274/// The old text was `CodeHash::try_from`'s `io::ErrorKind::InvalidData`, which
275/// stringifies to "invalid data": nothing a browser developer can act on. Name
276/// the field, the expected length, what actually arrived, and why it matters.
277///
278/// Be precise about WHY, because the obvious phrasing is wrong. The node does
279/// NOT resolve a contract's WASM by code hash. It resolves by INSTANCE id:
280/// `ContractStore::fetch_contract` recovers the real hash from
281/// `key_to_code_part[key.id()]`, the module cache keys on `ContractKey` whose
282/// `Hash`/`Eq` use only the instance, and the state stores key on
283/// `as_bytes()`, which is instance bytes. The hash is load-bearing at exactly
284/// one place: the gate that asks whether this node already holds the code
285/// blob. Saying otherwise would tell a reader the hash is fundamentally
286/// required, which is the opposite of what freenet-core#4978 argues, and #4978
287/// is the fix we point them at.
288fn code_hash_error(observed: Option<usize>) -> String {
289    let seen = match observed {
290        Some(len) => format!("got {len} bytes"),
291        None => "the field was absent".to_string(),
292    };
293    format!(
294        "ContractKey.code must be the {CONTRACT_KEY_SIZE}-byte contract code hash; {seen}. \
295         An UPDATE cannot be addressed by instance id alone: the node gates an UPDATE on \
296         already holding the contract's code blob and probes for it by code hash, so a \
297         missing or wrong-length hash is rejected here instead of failing later as \
298         \"missing contract: <key>\". Build the key with both parts: in the TypeScript SDK \
299         use `new ContractKey(instance, code)` rather than \
300         `ContractKey.fromInstanceId(...)`. See freenet/freenet-core#4978."
301    )
302}
303
304/// Decode a wire `ContractInstanceId`'s bytes, rejecting a wrong length instead
305/// of panicking.
306///
307/// **This is the only correct way to turn wire bytes into a
308/// `ContractInstanceId`.** The wire carries the id as 32 RAW bytes — every
309/// encode site writes `key.as_bytes()` and the TypeScript SDK writes
310/// `Array.from(instance)` — so the decode is a length-checked pass-through and
311/// nothing else. In particular do NOT reach for
312/// [`ContractInstanceId::from_base58`]: that is a base58 *string* decoder, and
313/// pointing it at raw bytes is what broke `related_to`/`instance_id` decoding
314/// (it panicked on every well-formed request, because a random 32-byte id
315/// essentially never consists solely of base58 characters).
316///
317/// `field` is the schema path being decoded. GET, SUBSCRIBE and UPDATE all pass
318/// `"ContractKey.instance.data"` because that genuinely is the field in all
319/// three (each request carries a `common.ContractKey`); the related-contract
320/// sites name their own distinct fields.
321pub(crate) fn instance_id_from_fbs(
322    field: &str,
323    data: &[u8],
324) -> Result<ContractInstanceId, WsApiError> {
325    crate::client_api::fixed_size_field::<CONTRACT_KEY_SIZE>(field, data)
326        .map(ContractInstanceId::new)
327}
328
329impl<'a> TryFromFbs<&FbsContractKey<'a>> for ContractKey {
330    fn try_decode_fbs(key: &FbsContractKey<'a>) -> Result<Self, WsApiError> {
331        let instance =
332            instance_id_from_fbs("ContractKey.instance.data", key.instance().data().bytes())?;
333        // The `code` field carries the already-computed 32-byte code hash
334        // (BLAKE3 of the wasm), so pass those bytes straight through. Calling
335        // `CodeHash::from_code` here would hash the hash again -
336        // BLAKE3(BLAKE3(wasm)): yielding a key that never matches the store and
337        // breaking every FlatBuffers UpdateRequest ("Contract not in store and
338        // no code provided"). GET/SUBSCRIBE dodged this because they decode only
339        // the instance id; UPDATE decodes the full key. The delegate decoder
340        // already does the pass-through correctly (see
341        // `DelegateKey::try_decode_fbs`). Regression test below.
342        //
343        // Anything other than exactly `CONTRACT_KEY_SIZE` bytes is rejected here,
344        // even though the schema marks `code` optional and the TypeScript SDK's
345        // `ContractKey.fromInstanceId(...)` emits a present-but-empty vector.
346        // That is a deliberate narrowing, not an oversight: `try_decode_fbs` is
347        // reached only from the UPDATE decode path, and an UPDATE genuinely needs
348        // the hash today: freenet-core gates on `code_blob_stored(key.code_hash())`
349        // and, because UPDATE supplies no contract code, a miss fails the request
350        // with "Contract not in store and no code provided". So an empty or absent
351        // `code` has never produced a working UPDATE; before this change it merely
352        // failed later, at the store gate, with an error pointing at the node
353        // rather than at the caller. Rejecting at the boundary with an actionable
354        // message is strictly better diagnostics for the same outcome.
355        //
356        // The honest end state is `Option<CodeHash>` threaded through
357        // `ContractRequest::Update`, once freenet-core resolves a `None` from the
358        // instance id the way GET and SUBSCRIBE already do via
359        // `code_hash_from_id`. That is tracked in freenet/freenet-core#4978; it
360        // needs a coordinated stdlib + core change, so it is not done here.
361        let code_bytes = key
362            .code()
363            .map(|code_hash| code_hash.bytes())
364            .ok_or_else(|| WsApiError::deserialization(code_hash_error(None)))?;
365        let code = CodeHash::try_from(code_bytes)
366            .map_err(|_| WsApiError::deserialization(code_hash_error(Some(code_bytes.len()))))?;
367        Ok(ContractKey { instance, code })
368    }
369}
370
371fn generate_id<'a>(
372    parameters: &Parameters<'a>,
373    code_data: &ContractCode<'a>,
374) -> ContractInstanceId {
375    let contract_hash = code_data.hash();
376
377    let mut hasher = Blake3::new();
378    hasher.update(contract_hash.0.as_slice());
379    hasher.update(parameters.as_ref());
380    let full_key_arr = hasher.finalize();
381
382    debug_assert_eq!(full_key_arr[..].len(), CONTRACT_KEY_SIZE);
383    let mut spec = [0; CONTRACT_KEY_SIZE];
384    spec.copy_from_slice(&full_key_arr);
385    ContractInstanceId(spec)
386}
387
388#[inline]
389pub(super) fn internal_fmt_key(
390    key: &[u8; CONTRACT_KEY_SIZE],
391    f: &mut std::fmt::Formatter<'_>,
392) -> std::fmt::Result {
393    let r = bs58::encode(key)
394        .with_alphabet(bs58::Alphabet::BITCOIN)
395        .into_string();
396    write!(f, "{}", &r[..8])
397}
398
399#[cfg(test)]
400mod fbs_tests {
401    use super::*;
402    use crate::common_generated::common::{
403        ContractInstanceId as FbsContractInstanceId, ContractInstanceIdArgs, ContractKeyArgs,
404    };
405
406    /// The wire `code` field carries the raw 32-byte code hash, and the decoder
407    /// must return those exact bytes. Regression for the double-hash bug where
408    /// `try_decode_fbs` re-hashed the hash (BLAKE3(BLAKE3(wasm))), producing a
409    /// key that never matched the store and failing every FlatBuffers
410    /// UpdateRequest with "Contract not in store and no code provided".
411    #[test]
412    fn contract_key_code_hash_passes_through_fbs_decode() {
413        // A distinct, arbitrary code hash. The decoder must reproduce it
414        // verbatim; if it re-hashes, the assertion below fails.
415        let code_bytes = [42u8; CONTRACT_KEY_SIZE];
416        let decoded = decode_with_code(Some(&code_bytes)).expect("decode ContractKey");
417
418        assert_eq!(
419            decoded.code_hash().as_ref(),
420            &code_bytes,
421            "decoder must pass the code hash through unchanged, not re-hash it"
422        );
423        assert_eq!(decoded.id().as_bytes(), &[7u8; CONTRACT_KEY_SIZE]);
424    }
425
426    /// Build a `ContractKey` flatbuffer whose `code` vector holds `code_bytes`,
427    /// or which omits the optional `code` field entirely when `code_bytes` is
428    /// `None`, and run it through the decoder.
429    fn decode_with_code(code_bytes: Option<&[u8]>) -> Result<ContractKey, WsApiError> {
430        decode_key(&[7u8; CONTRACT_KEY_SIZE], code_bytes)
431    }
432
433    /// Serialize a `ContractKey` flatbuffer with the given raw field bytes.
434    /// Neither vector is length-checked here on purpose: the point is to feed
435    /// the decoder exactly what a peer could put on the wire.
436    fn encode_key(instance_bytes: &[u8], code_bytes: Option<&[u8]>) -> Vec<u8> {
437        let mut builder = flatbuffers::FlatBufferBuilder::new();
438        let instance_data = builder.create_vector(instance_bytes);
439        let instance_offset = FbsContractInstanceId::create(
440            &mut builder,
441            &ContractInstanceIdArgs {
442                data: Some(instance_data),
443            },
444        );
445        let code = code_bytes.map(|bytes| builder.create_vector(bytes));
446        let key_offset = FbsContractKey::create(
447            &mut builder,
448            &ContractKeyArgs {
449                instance: Some(instance_offset),
450                code,
451            },
452        );
453        builder.finish_minimal(key_offset);
454        builder.finished_data().to_vec()
455    }
456
457    fn decode_key(
458        instance_bytes: &[u8],
459        code_bytes: Option<&[u8]>,
460    ) -> Result<ContractKey, WsApiError> {
461        let bytes = encode_key(instance_bytes, code_bytes);
462        let fbs_key =
463            flatbuffers::root::<FbsContractKey>(&bytes).expect("valid ContractKey flatbuffer");
464        ContractKey::try_decode_fbs(&fbs_key)
465    }
466
467    /// FIXED BYTES pinning the `common.ContractKey` vtable layout.
468    ///
469    /// Why a blob when the sibling tests build programmatically: a
470    /// programmatic test encodes with the SAME generated code it then decodes
471    /// with, so a `common.fbs` change that reorders `instance` and `code` moves
472    /// both sides together and the test stays green while real TypeScript
473    /// clients break. Only bytes frozen OUTSIDE the generated code catch that.
474    /// This restores a property the PR briefly lost: after rebuilding the
475    /// update fixture programmatically, nothing decoded a `common.ContractKey`
476    /// from fixed bytes at all (the PUT fixture recomputes its key from
477    /// params+code and never reads the table; GET/SUBSCRIBE read only instance
478    /// bytes). Follows `delegate_interface.rs`'s `*_wire_format_is_stable`.
479    ///
480    /// Distinct from the TypeScript-blob test in `client_api::client_events`,
481    /// which pins the REJECT path. This one must pin a SUCCESSFUL decode.
482    ///
483    /// To regenerate after a deliberate schema change: build the same key with
484    /// `encode_key(&[7; 32], Some(&[42; 32]))` and paste `finished_data()`.
485    /// Changing these bytes is a wire-format break; be sure that is intended.
486    const CONTRACT_KEY_WIRE_FORMAT: &[u8] = &[
487        12, 0, 0, 0, 8, 0, 12, 0, 4, 0, 8, 0, 8, 0, 0, 0, 52, 0, 0, 0, 4, 0, 0, 0, 32, 0, 0, 0, 42,
488        42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42,
489        42, 42, 42, 42, 42, 42, 42, 42, 0, 0, 6, 0, 8, 0, 4, 0, 6, 0, 0, 0, 4, 0, 0, 0, 32, 0, 0,
490        0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
491        7, 7, 7,
492    ];
493
494    #[test]
495    fn contract_key_wire_format_is_stable() {
496        let fbs_key = flatbuffers::root::<FbsContractKey>(CONTRACT_KEY_WIRE_FORMAT)
497            .expect("the pinned ContractKey bytes must still parse");
498        let decoded = ContractKey::try_decode_fbs(&fbs_key)
499            .expect("the pinned ContractKey bytes must still decode");
500        assert_eq!(
501            decoded.id().as_bytes(),
502            &[7u8; CONTRACT_KEY_SIZE],
503            "instance id moved: `common.ContractKey`'s vtable layout changed, \
504             which breaks every already-deployed client"
505        );
506        assert_eq!(
507            decoded.code_hash().as_ref(),
508            &[42u8; CONTRACT_KEY_SIZE],
509            "code hash moved: `common.ContractKey`'s vtable layout changed, \
510             which breaks every already-deployed client"
511        );
512    }
513
514    /// A wrong-length `instance` is rejected, not panicked on.
515    ///
516    /// `instance`/`data` are `(required)` in the schema, but the flatbuffers
517    /// verifier checks PRESENCE, not LENGTH: so `flatbuffers::root` accepts an
518    /// 8-byte instance and the `try_into().unwrap()` this replaced then panicked
519    /// with `TryFromSliceError`. Nothing catches unwind on this path and
520    /// `panic = "abort"` is not set, so it killed the client's connection task:
521    /// a remote, wire-reachable panic reachable from UPDATE, GET and SUBSCRIBE.
522    #[test]
523    fn contract_key_decode_rejects_short_instance_without_panicking() {
524        let err = decode_key(&[1u8; 8], Some(&[42u8; CONTRACT_KEY_SIZE]))
525            .expect_err("a short instance vector must be rejected");
526        let msg = err.to_string();
527        assert!(
528            msg.contains("ContractKey.instance") && msg.contains("got 8 bytes"),
529            "the error must name the field and the observed length, got: {msg}"
530        );
531    }
532
533    /// An over-long `instance` is rejected too: the guard is a length equality,
534    /// not a minimum, so a peer cannot pad its way past it.
535    #[test]
536    fn contract_key_decode_rejects_long_instance() {
537        let err = decode_key(&[1u8; 64], Some(&[42u8; CONTRACT_KEY_SIZE]))
538            .expect_err("an over-long instance vector must be rejected");
539        assert!(err.to_string().contains("got 64 bytes"), "got: {err}");
540    }
541
542    /// A zero-length `code` is rejected, and the error says what to do about it.
543    ///
544    /// This pins BOTH halves of the decision, because each is easy to undo
545    /// without noticing the other:
546    ///
547    /// 1. The rejection itself. The schema marks `code` optional and the
548    ///    TypeScript SDK's `ContractKey.fromInstanceId(...)` emits exactly this
549    ///    shape, so narrowing to "32 bytes or nothing" is a deliberate call, not
550    ///    an accident of using `CodeHash::try_from`. It is safe because an
551    ///    UPDATE carrying no code hash could never be served anyway: the node
552    ///    resolves the WASM by code hash and fails at the store gate.
553    /// 2. The message. The whole point of rejecting early is diagnosis: the
554    ///    previous text was `io::ErrorKind::InvalidData`, which stringifies to
555    ///    "invalid data" and told a browser developer nothing. Reverting to a
556    ///    bare `try_from` error would keep this test's first assertion green
557    ///    while destroying the reason the change was made, so the message
558    ///    content is asserted too.
559    #[test]
560    fn contract_key_decode_rejects_empty_code_with_actionable_error() {
561        let err = decode_with_code(Some(&[])).expect_err("empty code must be rejected");
562        let msg = err.to_string();
563
564        assert!(
565            msg.contains("ContractKey.code"),
566            "error must name the offending field, got: {msg}"
567        );
568        assert!(
569            msg.contains("32-byte"),
570            "error must state the expected length, got: {msg}"
571        );
572        assert!(
573            msg.contains("got 0 bytes"),
574            "error must state the actual length, got: {msg}"
575        );
576        assert!(
577            msg.contains("instance id alone"),
578            "error must explain why the hash is required, got: {msg}"
579        );
580        assert!(
581            msg.contains("4978"),
582            "error must point at the tracking issue for the real fix, got: {msg}"
583        );
584    }
585
586    /// An absent `code` is rejected the same way. Unreachable from any
587    /// first-party producer: the TypeScript `pack()` always emits the vector
588    /// and the Rust stdlib has no client-to-node FBS request encoder: so this
589    /// only guards hand-rolled third-party encoders. Behavior is unchanged from
590    /// before this PR (it was already rejected); only the message improved, so
591    /// what is pinned here is that the two paths stay consistent.
592    #[test]
593    fn contract_key_decode_rejects_absent_code_with_actionable_error() {
594        let err = decode_with_code(None).expect_err("absent code must be rejected");
595        let msg = err.to_string();
596
597        assert!(
598            msg.contains("ContractKey.code") && msg.contains("4978"),
599            "absent-code error must carry the same guidance as the empty case, got: {msg}"
600        );
601        assert!(
602            msg.contains("absent"),
603            "absent-code error must distinguish itself from a wrong-length one, got: {msg}"
604        );
605    }
606
607    /// A wrong-but-nonzero length is rejected with the length it saw. Guards the
608    /// obvious partial fix of special-casing only the empty vector.
609    #[test]
610    fn contract_key_decode_rejects_wrong_length_code() {
611        let err = decode_with_code(Some(&[1u8; 16])).expect_err("16-byte code must be rejected");
612        assert!(
613            err.to_string().contains("got 16 bytes"),
614            "error must report the observed length, got: {err}"
615        );
616    }
617}