Skip to main content

tonutils_macros/
lib.rs

1//! Proc-macro derive for the `tonutils` TL-B runtime traits.
2
3use proc_macro::TokenStream;
4use quote::{format_ident, quote};
5use syn::{
6    Attribute, Data, DeriveInput, Expr, Fields, Ident, Lit, Result, Token, Type, parse_macro_input,
7    spanned::Spanned,
8};
9
10#[proc_macro_derive(Tlb, attributes(tlb))]
11pub fn derive_tlb(input: TokenStream) -> TokenStream {
12    let input = parse_macro_input!(input as DeriveInput);
13    expand_tlb(input)
14        .unwrap_or_else(syn::Error::into_compile_error)
15        .into()
16}
17
18#[proc_macro_derive(Contract, attributes(contract))]
19pub fn derive_contract(input: TokenStream) -> TokenStream {
20    let input = parse_macro_input!(input as DeriveInput);
21    expand_contract(input)
22        .unwrap_or_else(syn::Error::into_compile_error)
23        .into()
24}
25
26fn expand_contract(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
27    let Data::Struct(data) = &input.data else {
28        return Err(syn::Error::new_spanned(
29            input.ident,
30            "Contract derive supports named structs with exactly one data field",
31        ));
32    };
33    let Fields::Named(fields) = &data.fields else {
34        return Err(syn::Error::new_spanned(
35            &data.fields,
36            "Contract derive supports named structs with exactly one data field",
37        ));
38    };
39    if fields.named.len() != 1 {
40        return Err(syn::Error::new_spanned(
41            &data.fields,
42            "Contract derive requires exactly one named field: data",
43        ));
44    }
45    let field = fields.named.first().expect("field count checked");
46    let Some(field_name) = &field.ident else {
47        return Err(syn::Error::new_spanned(
48            field,
49            "Contract derive requires a named data field",
50        ));
51    };
52    if field_name != "data" {
53        return Err(syn::Error::new_spanned(
54            field_name,
55            "Contract derive requires the field to be named data",
56        ));
57    }
58
59    let config = contract_config(&input.attrs)?;
60    let code_tokens = config.code_tokens()?;
61    let workchain = config.workchain;
62    let name = &input.ident;
63    let data_ty = &field.ty;
64    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
65
66    Ok(quote! {
67        impl #impl_generics ::tonutils::contracts::ContractBlueprint for #name #ty_generics #where_clause {
68            type Data = #data_ty;
69
70            fn data(&self) -> &Self::Data {
71                &self.data
72            }
73
74            fn code_boc(&self) -> ::std::borrow::Cow<'static, [u8]> {
75                #code_tokens
76            }
77
78            fn workchain(&self) -> i8 {
79                #workchain
80            }
81        }
82    })
83}
84
85fn expand_tlb(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
86    match input.data {
87        Data::Struct(data) => expand_struct(&input.ident, &input.attrs, &data.fields),
88        Data::Enum(data) => {
89            expand_enum(&input.ident, &data.variants.into_iter().collect::<Vec<_>>())
90        }
91        Data::Union(_) => Err(syn::Error::new_spanned(
92            input.ident,
93            "TL-B derive does not support unions",
94        )),
95    }
96}
97
98fn expand_struct(
99    name: &Ident,
100    attrs: &[Attribute],
101    fields: &Fields,
102) -> Result<proc_macro2::TokenStream> {
103    let tag = tlb_tag(attrs)?;
104    let field_specs = field_specs(fields)?;
105    let store_fields = field_specs.iter().map(|field| {
106        let access = &field.access;
107        field.store_tokens(quote!(&self.#access))
108    });
109    let load_fields = field_specs.iter().map(|field| {
110        let binding = &field.binding;
111        let load = field.load_tokens();
112        quote!(let #binding = #load;)
113    });
114    let construct = construct_struct(name, fields, &field_specs);
115    let store_tag = tag
116        .as_deref()
117        .map(|tag| quote!(::tonutils::tlb::store_tag(builder, #tag)?;))
118        .unwrap_or_default();
119    let load_tag = tag
120        .as_deref()
121        .map(|tag| quote!(::tonutils::tlb::expect_tag(slice, stringify!(#name), #tag)?;))
122        .unwrap_or_default();
123
124    Ok(quote! {
125        impl ::tonutils::tlb::TlbSerialize for #name {
126            fn store_tlb(&self, builder: &mut ::tonutils::tvm::Builder) -> ::tonutils::tlb::Result<()> {
127                #store_tag
128                #(#store_fields)*
129                Ok(())
130            }
131        }
132
133        impl ::tonutils::tlb::TlbDeserialize for #name {
134            fn load_tlb(slice: &mut ::tonutils::tvm::Slice) -> ::tonutils::tlb::Result<Self> {
135                #load_tag
136                #(#load_fields)*
137                Ok(#construct)
138            }
139        }
140    })
141}
142
143fn expand_enum(name: &Ident, variants: &[syn::Variant]) -> Result<proc_macro2::TokenStream> {
144    let mut store_arms = Vec::new();
145    let mut load_arms = Vec::new();
146    let mut expected_tags = Vec::new();
147    let max_tag_len = variants
148        .iter()
149        .filter_map(|variant| tlb_tag(&variant.attrs).ok().flatten())
150        .map(|tag| tag.len())
151        .max()
152        .unwrap_or(0);
153
154    for variant in variants {
155        let variant_name = &variant.ident;
156        let tag = tlb_tag(&variant.attrs)?.ok_or_else(|| {
157            syn::Error::new_spanned(
158                variant_name,
159                "TL-B enum variants require #[tlb(tag = \"...\")]",
160            )
161        })?;
162        expected_tags.push(tag.clone());
163        let specs = field_specs(&variant.fields)?;
164        let bindings = specs.iter().map(|field| &field.binding).collect::<Vec<_>>();
165        let pattern = match &variant.fields {
166            Fields::Named(_) => quote!(#name::#variant_name { #(#bindings),* }),
167            Fields::Unnamed(_) => quote!(#name::#variant_name(#(#bindings),*)),
168            Fields::Unit => quote!(#name::#variant_name),
169        };
170        let store_fields = specs.iter().map(|field| {
171            let binding = &field.binding;
172            field.store_tokens(quote!(#binding))
173        });
174        store_arms.push(quote! {
175            #pattern => {
176                ::tonutils::tlb::store_tag(builder, #tag)?;
177                #(#store_fields)*
178            }
179        });
180
181        let load_fields = specs.iter().map(|field| {
182            let binding = &field.binding;
183            let load = field.load_tokens();
184            quote!(let #binding = #load;)
185        });
186        let construct = construct_variant(name, variant_name, &variant.fields, &specs);
187        load_arms.push(quote! {
188            #tag => {
189                #(#load_fields)*
190                return Ok(#construct);
191            }
192        });
193    }
194    let expected = expected_tags.join("|");
195
196    Ok(quote! {
197        impl ::tonutils::tlb::TlbSerialize for #name {
198            fn store_tlb(&self, builder: &mut ::tonutils::tvm::Builder) -> ::tonutils::tlb::Result<()> {
199                match self {
200                    #(#store_arms),*
201                }
202                Ok(())
203            }
204        }
205
206        impl ::tonutils::tlb::TlbDeserialize for #name {
207            fn load_tlb(slice: &mut ::tonutils::tvm::Slice) -> ::tonutils::tlb::Result<Self> {
208                let mut actual = String::new();
209                while actual.len() < #max_tag_len {
210                    let bit = slice.load_bit()?;
211                    actual.push(if bit { '1' } else { '0' });
212                    match actual.as_str() {
213                        #(#load_arms)*
214                        _ => {}
215                    }
216                }
217                Err(::tonutils::tlb::TlbError::TagMismatch {
218                    constructor: stringify!(#name),
219                    expected_bits: #expected,
220                    actual_bits: actual,
221                })
222            }
223        }
224    })
225}
226
227#[derive(Clone)]
228struct FieldSpec {
229    binding: Ident,
230    access: proc_macro2::TokenStream,
231    ty: Type,
232    bits: Option<usize>,
233    referenced: bool,
234}
235
236impl FieldSpec {
237    fn store_tokens(&self, value: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
238        if self.referenced {
239            return quote!(::tonutils::tlb::store_ref_tlb(builder, #value)?;);
240        }
241        if let Some(bits) = self.bits {
242            return quote!(::tonutils::tlb::StoreBits::<#bits>::store_bits_tlb(#value, builder)?;);
243        }
244        quote!(::tonutils::tlb::TlbSerialize::store_tlb(#value, builder)?;)
245    }
246
247    fn load_tokens(&self) -> proc_macro2::TokenStream {
248        let ty = &self.ty;
249        if self.referenced {
250            return quote!(::tonutils::tlb::load_ref_tlb::<#ty>(slice, stringify!(#ty))?);
251        }
252        if let Some(bits) = self.bits {
253            return quote!(<#ty as ::tonutils::tlb::LoadBits<#bits>>::load_bits_tlb(slice)?);
254        }
255        quote!(<#ty as ::tonutils::tlb::TlbDeserialize>::load_tlb(slice)?)
256    }
257}
258
259fn field_specs(fields: &Fields) -> Result<Vec<FieldSpec>> {
260    fields
261        .iter()
262        .enumerate()
263        .map(|(index, field)| {
264            let binding = field
265                .ident
266                .clone()
267                .unwrap_or_else(|| format_ident!("field_{index}"));
268            let access = field
269                .ident
270                .as_ref()
271                .map(|ident| quote!(#ident))
272                .unwrap_or_else(|| {
273                    let index = syn::Index::from(index);
274                    quote!(#index)
275                });
276            Ok(FieldSpec {
277                binding,
278                access,
279                ty: field.ty.clone(),
280                bits: field_bits(field)?,
281                referenced: tlb_flag(&field.attrs, "reference")? || tlb_flag(&field.attrs, "ref")?,
282            })
283        })
284        .collect()
285}
286
287fn field_bits(field: &syn::Field) -> Result<Option<usize>> {
288    if is_float_primitive(&field.ty) {
289        return Err(syn::Error::new_spanned(
290            &field.ty,
291            "float primitive TL-B fields are not supported by the runtime",
292        ));
293    }
294    if let Some(bits) = tlb_bits(&field.attrs)? {
295        return Ok(Some(bits));
296    }
297    if let Some(bits) = inferred_unsigned_bits(&field.ty) {
298        return Ok(Some(bits));
299    }
300    if requires_explicit_bits(&field.ty) {
301        return Err(syn::Error::new_spanned(
302            &field.ty,
303            "signed integer and float TL-B fields require #[tlb(bits = N)]",
304        ));
305    }
306    Ok(None)
307}
308
309fn inferred_unsigned_bits(ty: &Type) -> Option<usize> {
310    match primitive_type_ident(ty)?.as_str() {
311        "u8" => Some(8),
312        "u16" => Some(16),
313        "u32" => Some(32),
314        "u64" => Some(64),
315        "u128" => Some(128),
316        _ => None,
317    }
318}
319
320fn requires_explicit_bits(ty: &Type) -> bool {
321    matches!(
322        primitive_type_ident(ty).as_deref(),
323        Some("i8" | "i16" | "i32" | "i64" | "i128" | "isize")
324    )
325}
326
327fn is_float_primitive(ty: &Type) -> bool {
328    matches!(primitive_type_ident(ty).as_deref(), Some("f32" | "f64"))
329}
330
331fn primitive_type_ident(ty: &Type) -> Option<String> {
332    let Type::Path(path) = ty else {
333        return None;
334    };
335    if path.qself.is_some() || path.path.segments.len() != 1 {
336        return None;
337    }
338    Some(path.path.segments.first()?.ident.to_string())
339}
340
341fn construct_struct(
342    name: &Ident,
343    fields: &Fields,
344    specs: &[FieldSpec],
345) -> proc_macro2::TokenStream {
346    let bindings = specs.iter().map(|field| &field.binding);
347    match fields {
348        Fields::Named(_) => quote!(#name { #(#bindings),* }),
349        Fields::Unnamed(_) => quote!(#name(#(#bindings),*)),
350        Fields::Unit => quote!(#name),
351    }
352}
353
354fn construct_variant(
355    name: &Ident,
356    variant: &Ident,
357    fields: &Fields,
358    specs: &[FieldSpec],
359) -> proc_macro2::TokenStream {
360    let bindings = specs.iter().map(|field| &field.binding);
361    match fields {
362        Fields::Named(_) => quote!(#name::#variant { #(#bindings),* }),
363        Fields::Unnamed(_) => quote!(#name::#variant(#(#bindings),*)),
364        Fields::Unit => quote!(#name::#variant),
365    }
366}
367
368fn tlb_tag(attrs: &[Attribute]) -> Result<Option<String>> {
369    let mut tag = None;
370    for attr in attrs.iter().filter(|attr| attr.path().is_ident("tlb")) {
371        attr.parse_nested_meta(|meta| {
372            if meta.path.is_ident("tag") {
373                let value = meta.value()?;
374                let lit: Lit = value.parse()?;
375                match lit {
376                    Lit::Str(lit) => {
377                        tag = Some(
378                            normalize_tag_literal(&lit.value())
379                                .map_err(|message| syn::Error::new(lit.span(), message))?,
380                        )
381                    }
382                    _ => return Err(meta.error("tag must be a string literal")),
383                }
384            }
385            Ok(())
386        })?;
387    }
388    Ok(tag)
389}
390
391fn tlb_bits(attrs: &[Attribute]) -> Result<Option<usize>> {
392    let mut bits = None;
393    for attr in attrs.iter().filter(|attr| attr.path().is_ident("tlb")) {
394        attr.parse_nested_meta(|meta| {
395            if meta.path.is_ident("bits") {
396                let value = meta.value()?;
397                let lit: Lit = value.parse()?;
398                if let Lit::Int(lit) = lit {
399                    bits = Some(lit.base10_parse()?);
400                    return Ok(());
401                }
402                return Err(meta.error("bits must be an integer literal"));
403            }
404            Ok(())
405        })?;
406    }
407    Ok(bits)
408}
409
410fn tlb_flag(attrs: &[Attribute], name: &str) -> Result<bool> {
411    let mut found = false;
412    for attr in attrs.iter().filter(|attr| attr.path().is_ident("tlb")) {
413        attr.parse_nested_meta(|meta| {
414            if meta.path.is_ident(name) {
415                found = true;
416            } else if meta.input.peek(Token![=]) {
417                let _ = meta.value()?.parse::<Lit>()?;
418            }
419            Ok(())
420        })?;
421    }
422    Ok(found)
423}
424
425#[derive(Default)]
426struct ContractConfig {
427    code: Option<ContractCodeSource>,
428    workchain: i8,
429}
430
431enum ContractCodeSource {
432    Expr(Expr),
433    Hex(Vec<u8>),
434    File(syn::LitStr),
435}
436
437impl ContractConfig {
438    fn code_tokens(&self) -> Result<proc_macro2::TokenStream> {
439        let source = self.code.as_ref().ok_or_else(|| {
440            syn::Error::new(
441                proc_macro2::Span::call_site(),
442                "Contract derive requires one code source: code, code_hex, or code_file",
443            )
444        })?;
445        Ok(match source {
446            ContractCodeSource::Expr(expr) => {
447                quote!(::std::borrow::Cow::Borrowed(#expr))
448            }
449            ContractCodeSource::Hex(bytes) => {
450                let bytes = bytes.iter().copied();
451                quote!(::std::borrow::Cow::Borrowed(&[#(#bytes),*]))
452            }
453            ContractCodeSource::File(path) => {
454                quote!(::std::borrow::Cow::Borrowed(include_bytes!(#path)))
455            }
456        })
457    }
458}
459
460fn contract_config(attrs: &[Attribute]) -> Result<ContractConfig> {
461    let mut config = ContractConfig {
462        code: None,
463        workchain: 0,
464    };
465    let mut workchain_seen = false;
466
467    for attr in attrs.iter().filter(|attr| attr.path().is_ident("contract")) {
468        attr.parse_nested_meta(|meta| {
469            if meta.path.is_ident("code") {
470                let value = meta.value()?;
471                let expr: Expr = value.parse()?;
472                set_contract_code(
473                    &mut config,
474                    ContractCodeSource::Expr(expr),
475                    meta.path.span(),
476                )?;
477            } else if meta.path.is_ident("code_hex") {
478                let value = meta.value()?;
479                let lit: Lit = value.parse()?;
480                let Lit::Str(lit) = lit else {
481                    return Err(meta.error("code_hex must be a string literal"));
482                };
483                let bytes = parse_hex_bytes(&lit.value())
484                    .map_err(|message| syn::Error::new(lit.span(), message))?;
485                set_contract_code(
486                    &mut config,
487                    ContractCodeSource::Hex(bytes),
488                    meta.path.span(),
489                )?;
490            } else if meta.path.is_ident("code_file") {
491                let value = meta.value()?;
492                let lit: Lit = value.parse()?;
493                let Lit::Str(lit) = lit else {
494                    return Err(meta.error("code_file must be a string literal"));
495                };
496                set_contract_code(&mut config, ContractCodeSource::File(lit), meta.path.span())?;
497            } else if meta.path.is_ident("workchain") {
498                if workchain_seen {
499                    return Err(meta.error("workchain specified more than once"));
500                }
501                workchain_seen = true;
502                let value = meta.value()?;
503                let lit: Lit = value.parse()?;
504                let Lit::Int(lit) = lit else {
505                    return Err(meta.error("workchain must be an integer literal"));
506                };
507                config.workchain = lit.base10_parse::<i8>()?;
508            } else {
509                return Err(meta.error("unsupported contract attribute"));
510            }
511            Ok(())
512        })?;
513    }
514    Ok(config)
515}
516
517fn set_contract_code(
518    config: &mut ContractConfig,
519    source: ContractCodeSource,
520    span: proc_macro2::Span,
521) -> Result<()> {
522    if config.code.is_some() {
523        return Err(syn::Error::new(
524            span,
525            "Contract derive accepts only one code source",
526        ));
527    }
528    config.code = Some(source);
529    Ok(())
530}
531
532fn parse_hex_bytes(raw: &str) -> std::result::Result<Vec<u8>, String> {
533    let hex = raw
534        .strip_prefix("0x")
535        .or_else(|| raw.strip_prefix("0X"))
536        .unwrap_or(raw)
537        .chars()
538        .filter(|ch| *ch != '_' && !ch.is_ascii_whitespace())
539        .collect::<String>();
540    if hex.is_empty() {
541        return Err("code_hex must not be empty".to_string());
542    }
543    if hex.len() % 2 != 0 {
544        return Err("code_hex must contain an even number of hex digits".to_string());
545    }
546
547    let mut bytes = Vec::with_capacity(hex.len() / 2);
548    for index in (0..hex.len()).step_by(2) {
549        let byte = u8::from_str_radix(&hex[index..index + 2], 16)
550            .map_err(|_| "code_hex must contain only hexadecimal digits".to_string())?;
551        bytes.push(byte);
552    }
553    Ok(bytes)
554}
555
556fn normalize_tag_literal(raw: &str) -> std::result::Result<String, String> {
557    if let Some(hex) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) {
558        return hex_tag_to_bits(hex);
559    }
560    if let Some(hex) = raw.strip_prefix('#') {
561        return hex_tag_to_bits(hex);
562    }
563    if let Some(bits) = raw.strip_prefix("0b").or_else(|| raw.strip_prefix("0B")) {
564        return binary_tag_to_bits(bits);
565    }
566    binary_tag_to_bits(raw)
567}
568
569fn binary_tag_to_bits(raw: &str) -> std::result::Result<String, String> {
570    let bits = raw.chars().filter(|ch| *ch != '_').collect::<String>();
571    if bits.is_empty() {
572        return Err("tag must not be empty".to_string());
573    }
574    if bits.chars().all(|ch| matches!(ch, '0' | '1')) {
575        Ok(bits)
576    } else {
577        Err("binary tag must contain only 0, 1, or _; use 0x... or #... for hex tags".to_string())
578    }
579}
580
581fn hex_tag_to_bits(raw: &str) -> std::result::Result<String, String> {
582    let hex = raw.chars().filter(|ch| *ch != '_').collect::<String>();
583    if hex.is_empty() {
584        return Err("hex tag must not be empty".to_string());
585    }
586
587    let mut bits = String::with_capacity(hex.len() * 4);
588    for ch in hex.chars() {
589        let value = ch
590            .to_digit(16)
591            .ok_or_else(|| "hex tag must contain only hexadecimal digits or _".to_string())?;
592        bits.push(if value & 0b1000 != 0 { '1' } else { '0' });
593        bits.push(if value & 0b0100 != 0 { '1' } else { '0' });
594        bits.push(if value & 0b0010 != 0 { '1' } else { '0' });
595        bits.push(if value & 0b0001 != 0 { '1' } else { '0' });
596    }
597    Ok(bits)
598}
599
600#[cfg(test)]
601mod tests {
602    use super::{
603        expand_contract, inferred_unsigned_bits, is_float_primitive, normalize_tag_literal,
604        parse_hex_bytes, requires_explicit_bits,
605    };
606    use syn::{DeriveInput, Type, parse_quote};
607
608    #[test]
609    fn tag_literals_accept_binary_and_hex_forms() {
610        assert_eq!(normalize_tag_literal("101").unwrap(), "101");
611        assert_eq!(normalize_tag_literal("0b10_01").unwrap(), "1001");
612        assert_eq!(
613            normalize_tag_literal("0x0f8a_7ea5").unwrap(),
614            "00001111100010100111111010100101"
615        );
616        assert_eq!(normalize_tag_literal("#A5").unwrap(), "10100101");
617    }
618
619    #[test]
620    fn tag_literals_reject_invalid_forms() {
621        assert!(normalize_tag_literal("").is_err());
622        assert!(normalize_tag_literal("102").is_err());
623        assert!(normalize_tag_literal("0x").is_err());
624        assert!(normalize_tag_literal("0xzz").is_err());
625    }
626
627    #[test]
628    fn unsigned_primitive_bits_are_inferred() {
629        let cases = [
630            (parse_quote!(u8), Some(8)),
631            (parse_quote!(u16), Some(16)),
632            (parse_quote!(u32), Some(32)),
633            (parse_quote!(u64), Some(64)),
634            (parse_quote!(u128), Some(128)),
635            (parse_quote!(usize), None),
636            (parse_quote!(Grams), None),
637        ];
638
639        for (ty, expected) in cases {
640            assert_eq!(inferred_unsigned_bits(&ty), expected);
641        }
642    }
643
644    #[test]
645    fn signed_and_float_primitives_require_explicit_bits() {
646        let required: [Type; 5] = [
647            parse_quote!(i8),
648            parse_quote!(i16),
649            parse_quote!(i32),
650            parse_quote!(i64),
651            parse_quote!(i128),
652        ];
653        for ty in required {
654            assert!(requires_explicit_bits(&ty));
655        }
656
657        assert!(!requires_explicit_bits(&parse_quote!(u64)));
658        assert!(!requires_explicit_bits(&parse_quote!(Grams)));
659    }
660
661    #[test]
662    fn float_primitives_are_rejected() {
663        assert!(is_float_primitive(&parse_quote!(f32)));
664        assert!(is_float_primitive(&parse_quote!(f64)));
665        assert!(!is_float_primitive(&parse_quote!(i64)));
666    }
667
668    #[test]
669    fn contract_code_hex_accepts_prefixes_underscores_and_whitespace() {
670        assert_eq!(
671            parse_hex_bytes("0xb5ee_9c72").unwrap(),
672            [0xb5, 0xee, 0x9c, 0x72]
673        );
674        assert_eq!(
675            parse_hex_bytes("b5 ee 9c 72").unwrap(),
676            [0xb5, 0xee, 0x9c, 0x72]
677        );
678    }
679
680    #[test]
681    fn contract_code_hex_rejects_empty_odd_and_invalid_values() {
682        assert!(parse_hex_bytes("").is_err());
683        assert!(parse_hex_bytes("abc").is_err());
684        assert!(parse_hex_bytes("zz").is_err());
685    }
686
687    #[test]
688    fn contract_derive_accepts_supported_code_sources() {
689        let const_input: DeriveInput = parse_quote! {
690            #[contract(code = CODE_BOC)]
691            struct Wallet {
692                data: WalletData,
693            }
694        };
695        assert!(expand_contract(const_input).is_ok());
696
697        let hex_input: DeriveInput = parse_quote! {
698            #[contract(code_hex = "b5ee9c72010101010002000000", workchain = -1)]
699            struct Wallet {
700                data: WalletData,
701            }
702        };
703        assert!(expand_contract(hex_input).is_ok());
704
705        let file_input: DeriveInput = parse_quote! {
706            #[contract(code_file = "wallet.code.boc")]
707            struct Wallet {
708                data: WalletData,
709            }
710        };
711        assert!(expand_contract(file_input).is_ok());
712    }
713
714    #[test]
715    fn contract_derive_rejects_ambiguous_or_unsupported_shapes() {
716        let missing_data: DeriveInput = parse_quote! {
717            #[contract(code = CODE_BOC)]
718            struct Wallet {
719                state: WalletData,
720            }
721        };
722        assert!(expand_contract(missing_data).is_err());
723
724        let extra_field: DeriveInput = parse_quote! {
725            #[contract(code = CODE_BOC)]
726            struct Wallet {
727                data: WalletData,
728                address: u32,
729            }
730        };
731        assert!(expand_contract(extra_field).is_err());
732
733        let unnamed: DeriveInput = parse_quote! {
734            #[contract(code = CODE_BOC)]
735            struct Wallet(WalletData);
736        };
737        assert!(expand_contract(unnamed).is_err());
738
739        let unit: DeriveInput = parse_quote! {
740            #[contract(code = CODE_BOC)]
741            struct Wallet;
742        };
743        assert!(expand_contract(unit).is_err());
744
745        let multiple_code_sources: DeriveInput = parse_quote! {
746            #[contract(code = CODE_BOC, code_hex = "00")]
747            struct Wallet {
748                data: WalletData,
749            }
750        };
751        assert!(expand_contract(multiple_code_sources).is_err());
752    }
753}