Skip to content

feat: add enum declarations and multi-arm enum match expressions#336

Draft
stringhandler wants to merge 6 commits into
BlockstreamResearch:masterfrom
stringhandler:feat/multiple-match-arms
Draft

feat: add enum declarations and multi-arm enum match expressions#336
stringhandler wants to merge 6 commits into
BlockstreamResearch:masterfrom
stringhandler:feat/multiple-match-arms

Conversation

@stringhandler

Copy link
Copy Markdown
Contributor

Introduces enum with explicit u8 discriminants and N-arm match over enum variants, desugared into jet::eq_8 comparison chains at the AST level. Missing witness values are zero-filled before Simplicity witness population; a post-prune check errors if any zero-filled witness appears on a surviving (non-pruned) branch.

Example

// last_will.simf


enum Action {
    Inherit=1,
    ColdSpend =2,
    HotSpend =3,
}

fn main() {
    match witness::ACTION {
        Action::Inherit => { 
            let inheritor_sig: Signature = witness::INHERITOR_SIG;
            inherit_spend(inheritor_sig)} ,
        Action::ColdSpend => {
            let cold_sig: Signature = witness::COLD_SIG;
            cold_spend(cold_sig) },
        Action::HotSpend => {
            let hot_sig: Signature = witness::HOT_SIG;
            refresh_spend(hot_sig) },
    }
}
// last_will.inherit.wit
{
    "ACTION": {
        "value": "1",
        "type": "u8"
    },
    "INHERITOR_SIG": {
        "value": "0x755201bb62b0a8b8d18fd12fc02951ea3998ba42bfc6664daaf8a0d2298cad43cdc21358c7c82f37654275dc2fea8c858adbe97bac92828b498a5a237004db6f",
        "type": "Signature"
    }
}

Implementation notes

Some things to note that are missing:

1. Enums with structured fields.

I originally wanted to implement enums as something like

enum Path {
   Inherit { signature: Signature}
}

or potentially even with tuples, like Inherit(<inner tuple>). This would certainly look cleaner in the witness file (i.e. as ACTION: { value = "Inherit("0x....")}, however the enum declaration is not present in scope when parsing the file and this makes decoding it difficult.
As a trade off enums must have a u8 encoding, so that they can be parsed from the witness. This explicit declaration should also help when the transaction is examined on the explorer.

Structured enums can possibly be implemented in a future PR.

2. Empty witness filling

By splitting the action/path witness variable into it's own u8, we now have to specify individual witness variables for each of the actions/paths taken in the program. This means that we would now have to specify values for all witnesses, even if they are not called.

I did a deep dive and confirmed that unused witness values are pruned, but calling a program with a witness file or programmatically is still inconvenient. To combat this, I've added a step to fill unspecified witness values with defaults of zero, and then an additional step to make sure that none of the filled values remain when pruned. This is done through satisfy_with_env, so lib callers should not have to do any extra lifting.

3. Enum values in code are out of scope

A limit of the implementation is that using enums in code is not supported. This should be done in another PR.
For example, the following is not supported:

enum Direction {
  Up =1,
  Down = 2
}

fn return_up() -> Direction {
  Direction::Up
}

This is unfortunate but the PR is already quite large and tricky to review.

4. Desugaring into jet::eq_u8 chains

Under the hood, the match statement is desugared into a list of ifs (actually match if you are specific), each with a jet::eq_u8.
While it might have been more efficient to utilize an Either::Left structure, hoping for a tree like evaluation, it was difficult to decode a u8 reliably into Left/Right nodes and allow for skipping enum values (e..g enum ACTION { One = 1, /* Two = 2 --deprecated */, Three = 3 } ). That said, it is not impossible, and I can (reluctantly) create another PR for that implementation if desired.

@stringhandler stringhandler requested a review from delta1 as a code owner May 28, 2026 12:40
@apoelstra

Copy link
Copy Markdown
Contributor

3666ffb needs rebase

@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch 2 times, most recently from ce66af6 to 0eb01e6 Compare May 28, 2026 13:08
@LesterEvSe

Copy link
Copy Markdown
Collaborator

0eb01e6 needs rebase

@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch from 0eb01e6 to 2fa2122 Compare June 29, 2026 13:01
@stringhandler stringhandler marked this pull request as draft June 29, 2026 13:03
@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch 4 times, most recently from e9d43cd to ad3760b Compare July 1, 2026 12:33
@LesterEvSe

Copy link
Copy Markdown
Collaborator

Since we added unstable feature support, I think we need to add the enum keyword to UnstableFeature. Check the doc/unstable-features.md and unstable.rs files for more information

Comment thread src/parse.rs Outdated
/// An enum declaration.
#[derive(Clone, Debug)]
pub struct EnumDeclaration {
file_id: usize,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Currently, file_id is accessible via the Span struct, so we can delete it here. Try to do something similar to the other structs inside the Item struct

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

removed

Comment thread src/ast.rs
Comment thread src/ast.rs
Comment thread src/ast.rs
Comment thread src/ast.rs
Comment thread src/ast.rs
Comment thread src/named.rs Outdated
(Inner::Disconnect(cc, _), Inner::Disconnect(cp, _)) => {
check_surviving_witnesses(cc, cp, zero_filled)
}
_ => unreachable!("unexpected structural mismatch between commit and pruned trees"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Another unreachable block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have replaced this with an Err

@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch from ad3760b to 0528aab Compare July 2, 2026 14:54
@stringhandler stringhandler marked this pull request as ready for review July 2, 2026 14:54
Add enum declarations and enum match expressions to SimplicityHL.

Enums are registered as u8 type aliases; each variant has a u8 discriminant
 Enum match expressions desugar to binary bool-match chains that
dispatch on discriminant equality, ensuring invalid discriminants fail via panic
rather than silently executing any arm.
@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch from 0528aab to cfa1116 Compare July 2, 2026 14:58
@stringhandler

Copy link
Copy Markdown
Contributor Author

Added to unstable features

@LesterEvSe LesterEvSe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ACK cfa1116; tested locally with just check and just check_fuzz

@LesterEvSe

Copy link
Copy Markdown
Collaborator

I talked with Kyrylo a bit, and he helped me find a few more crucial bugs in the code

@LesterEvSe

LesterEvSe commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

imported enums lose their variant metadata.
Action resolves as a TYPE (u8 alias) after use, so let a: Action = ...
works, but matching Action::A fails because the EnumBinding variant list
was never copied into the importing module's scope.

Test case to reproduce:

mod m {
    pub enum Action {
        A = 0,
        B = 1,
    }
}

use crate::m::Action;

fn main() {
    let a: Action = 0;
    match a {
        Action::A => { },
        Action::B => { },
    }
}

So this code failed with a Type alias Action is not defined error

@LesterEvSe

Copy link
Copy Markdown
Collaborator

The zero-fill safety check is skipped when env = None.

The program has two branches. Branch B needs witness B. We select branch B (SELECTOR = 2) but do not provide witness B, and we ask for zero-filling.

With env = Some(..) -> pruning runs, check_surviving_witnesses runs, the fabricated zero B is detected.
With env = None -> pruning is skipped, so the check is skipped too, and the fabricated zero B slips through.

A missing live witness (e.g. a required signature) is silently forged as zero.

You can test it with adding this code to the tests/ directory:

use simplicityhl::ast::ElementsJetHinter;
use simplicityhl::str::WitnessName;
use simplicityhl::value::ValueConstructible;
use simplicityhl::{Arguments, CompiledProgram, UnstableFeatures, Value, WitnessValues};
use std::collections::HashMap;

const SRC: &str = r#"
enum Branch { A = 1, B = 2 }
fn main() {
    match witness::SELECTOR {
        Branch::A => assert!(jet::is_zero_32(witness::A)),
        Branch::B => assert!(jet::is_zero_32(witness::B)),
    }
}
"#;

fn compile() -> CompiledProgram {
    CompiledProgram::new_with_unstable(
        SRC,
        &UnstableFeatures::all(),
        Arguments::default(),
        false,
        Box::new(ElementsJetHinter::new()),
    )
    .unwrap()
}

#[test]
fn zerofill_env_none_skips_safety_check() {
    let mut map: HashMap<WitnessName, Value> = HashMap::new();
    map.insert(WitnessName::from_str_unchecked("SELECTOR"), Value::u8(2));
    // NOTE: witness `B` is intentionally NOT provided.

    let env = simplicityhl::dummy_env::dummy();
    let with_env = compile().satisfy_with_env_fill_missing(
        WitnessValues::from(map.clone()),
        Some(&env),
        true,
    );
    assert!(
        with_env.is_err(),
        "sanity check: with env, the missing live witness B must be rejected"
    );
    println!("with env  -> Err (correct): {}", with_env.unwrap_err());

    let without_env =
        compile().satisfy_with_env_fill_missing(WitnessValues::from(map), None, true);
    println!(
        "without env -> {}",
        if without_env.is_ok() { "Ok(satisfied)" } else { "Err" }
    );

    assert!(
        without_env.is_err(),
        "BUG: without env, missing live witness B was zero-filled and accepted"
    );
}

@LesterEvSe

Copy link
Copy Markdown
Collaborator

I tried to run the shipped example with cargo run --quiet -- -Z enums examples/last_will.simf --wit examples/last_will.inherit.wit and got Error: "missing witness for HOT_SIG".

I think this is the same root cause as the env = None issue: the CLI still calls the strict satisfy main.rs:225, so it requires a value for every witness

Add explicit destructuring of ModuleScope fields in both Phase 1 (collection)
and Phase 2 (insertion) of the use resolution process. This ensures that any
future fields added to ModuleScope will cause compile errors here until they
are explicitly handled, preventing silent bugs like the missed enum handling.

Also fix enum importing by tracking enum bindings alongside their aliases and
inserting them into the current scope when their corresponding alias is
importable.
Remove outdated comment about exhaustive destructuring that is no longer
relevant. Reformat three variable assignments to fit within line length
limits by placing them on single lines instead of breaking across
multiple lines.
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.

3 participants