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}