Skip to content

macro: Validate custom handler signatures and clarify dispatch errors#19

Open
moCello wants to merge 2 commits intomainfrom
mocello/233-macro-code-fixes
Open

macro: Validate custom handler signatures and clarify dispatch errors#19
moCello wants to merge 2 commits intomainfrom
mocello/233-macro-code-fixes

Conversation

@moCello
Copy link
Copy Markdown
Member

@moCello moCello commented Apr 16, 2026

Summary

Two coupled fixes to the contract-macro/ diagnostic surface around custom data-driver handlers. Supersedes task 189 (re-scope).

1. Compile-time handler signature validation

Handler functions registered via #[contract(encode_input = "…")], decode_input, or decode_output are spliced into the generated data_driver module and called directly by the dispatch match arms. A handler with the wrong shape (argument count, argument type, return type) used to slip past the macro and fail downstream with a cryptic type error against code the user didn't write.

Adds a validator that runs over each extracted handler and emits a clear compile_error! at the handler's definition naming the handler, the role, and the expected signature. Types are compared structurally — Vec<u8> (after use alloc::vec::Vec) or Error (after use dusk_data_driver::Error) are accepted, while foo::Error and MyError are rejected.

2. Role-specific runtime errors at the three dispatch sites

The three data-driver dispatch sites (encode_input_fn, decode_input_fn, decode_output_fn) used to return the same vague "custom handler required: {fn}" when a method marked #[contract(custom)] was dispatched without a matching handler. The user couldn't tell which of the three roles they were missing a handler for, nor the signature that role expects.

Each site now emits a role-tailored message that names the role and the expected handler signature in concrete types, so the reader can fix the handler from the error alone.

Single source of truth

Both the validator and the dispatch error messages pull the canonical per-role signature from data_driver::handler_signature, so the two can't drift apart.

Tests

  • 9 unit tests in validate::custom_handler — positive regression per role plus negatives for wrong arg count, wrong arg type, wrong return type, missing return, self receiver, foo::Error prefix, MyError last segment, &mut str mutability.
  • 3 trybuild compile-fail fixtures — one per role, each exercising a different mistake type with verbatim stderr snapshots.
  • 3 existing data_driver unit tests updated (not deleted) to assert on the new role-specific error content.
  • 4 tests pinning the idiomatic short-path forms (Vec<u8>, Error, JsonValue, &'static str).
  • Existing test-contract integration tests (2 handlers, encode_input + decode_output) still pass — no regression for existing users.

moCello added 2 commits April 16, 2026 15:45
Handler functions registered via `#[contract(encode_input = "…")]`,
`decode_input`, or `decode_output` are spliced into the generated
`data_driver` module and called directly by the dispatch match arms. A
handler with the wrong shape (argument count, argument type, return
type) used to slip past the macro and fail downstream with a cryptic
type error against code the user didn't write.

Add a compile-time validator that runs over each extracted handler and
emits a clear `compile_error!` at the handler's definition naming the
handler, the role, and the expected signature. The canonical per-role
signature is now centralized in `data_driver::handler_signature` so the
validator and the dispatch code that calls handlers can't drift apart.
The three data-driver dispatch sites (`encode_input_fn`,
`decode_input_fn`, `decode_output_fn`) used to return the same vague
`"custom handler required: {fn}"` when a method marked `#[contract(custom)]`
was dispatched without a matching handler. The user couldn't tell which
of the three roles they were missing a handler for, nor the signature
that role expects.

Replace each site with a role-tailored message that names the role
(`encode_input` / `decode_input` / `decode_output`) and the expected
handler signature in concrete types, so the reader can fix the handler
from the error alone. The message comes from
`data_driver::handler_signature_display`, the same source of truth the
compile-time handler validator uses.
@moCello moCello requested a review from HDauven April 16, 2026 14:47
Copy link
Copy Markdown
Member

@HDauven HDauven left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but there's two edge cases I think we need to handle. I'll leave it up to you to decide whether they're follow-ups or not.

/// equality.
fn types_match(expected: &Type, actual: &Type) -> bool {
match (expected, actual) {
(Type::Reference(e), Type::Reference(a)) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not handling lifetime explicitly here means &'static str is accepted as matching &str, but the generated dispatcher cannot satisfy a 'static borrow. So this still allows for a downstream expansion failure after validation already said ok.

To fix that you'd need to make the rule as follows:

  • No explicit lifetime: ok
  • Lifetime is generic on the handler: ok
  • Lifetime is 'static: reject


#[test]
fn test_custom_handler_encode_input_short_error_path() {
// `use dusk_data_driver::Error;` → user writes `Error` bare.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think the expansion works as intended here.

I tried the following minimal example:

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
    use dusk_data_driver::{Error, JsonValue};

    pub struct MyContract {
        value: u64,
    }

    impl MyContract {
        pub const fn new() -> Self {
            Self { value: 0 }
        }

        #[contract(custom)]
        pub fn get_value(&self) -> u64 {
            self.value
        }
    }

    #[contract(decode_output = "get_value")]
    fn decode_value(bytes: &[u8]) -> Result<JsonValue, Error> {
        let _ = bytes;
        unimplemented!()
    }
}

Which fails with:

error[E0425]: cannot find type `JsonValue` in this scope
error[E0425]: cannot find type `Error` in this scope

So these short-path forms are accepted by validation but fail on expansion once the handler is moved into the generated data_driver module.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants