Skip to content

New public statements design#514

Merged
ed255 merged 30 commits into
mainfrom
feat/new-public-statements
May 25, 2026
Merged

New public statements design#514
ed255 merged 30 commits into
mainfrom
feat/new-public-statements

Conversation

@ed255
Copy link
Copy Markdown
Collaborator

@ed255 ed255 commented May 19, 2026

Redesign how public statements are handled.

Previously public statements were a hashed list of consecutive statements. We had an area of the circuit to open all the public statements of input pods, and an area to copy statements to be public in the current pod.

Now the public statements live in a tree of parametrizable depth. We remove the area of input pod public statements and the area of current pod public statements. Instead we introduce an operation to open a public statement from an input pod (which consumes a regular statement slot), which is needed when we want to use an input statement. And each statement slot can become a public statement of the current pod, with a limit on the total by a parameter field.

Both the opening input pod statement and the making a statement public use tables, so the number of times such operations can be made is lower than the total number of statements.

Moreover, a pod can start from an empty tree of statements and only output public statements generated in that pod, or it can extend the tree of statements from the first input pod, which allows us to carry statements forward in a recursive chain of pods at no cost.

Aside from this change

  • I've removed the Copy operation because it's no longer needed. We used it previously to make a private or input statement public by copying it over the public area. But now any statement can be public.
  • Applied a bugfix I saw in Split aux tables based on size #504 in CustomPredicateVerifyQueryTarget::size
  • Removed passing sts_hash in the serialize/deserialize operations, because that value is better derived from the list of statements for a simpler verification flow.
  • Renamed self_statement to raw_statement because we no longer have the concept of self in a pod (we only have it in a custom predicate module). The raw_statement is used to build the statement tree, and for introduction pods it has an empty placeholder instead of their verifier data hash that needs to be replaced by a normalization step.
  • Added a method to the Pod trait to get the public statement tree
  • The MainPodBuilder will automatically open an input pod statement if an operation depends on it and is not found in the previous statement. This makes the API behave like before.
  • Change how we distinguish MainPod from IntroPod. An IntroPod was previously detected by inspecting the first statement and looking for an intro statement with blank verifier data hash. Now this would require an opening, so I've extended the public inputs to include a field that signals when the pod is main. If the pod is main, it's verifier data hash must be in the VDSet; so no intro can lie about it. If it claims to be intro, when we open a statement we validate that it's indeed intro and place its verifier data hash inside.

Params:

Params {
    max_input_pods: 2,
    max_statements: 48,
    max_public_statements: 20,
    max_open_input_statements: 20,
    max_custom_predicates: 10,
    max_custom_predicate_verifications: 10,
    max_custom_predicate_wildcards: 8,
    containers: ParamsContainers {
        state: ParamsMerkleProofs {
            max_small: 22,
            max_medium: 8,
        },
        transition: ParamsMerkleProofs {
            max_small: 12,
            max_medium: 6,
        },
        max_depth_small: 8,
        max_depth_medium: 32,
    },
    max_depth_mt_vds: 6,
    max_public_key_of: 2,
    max_signed_by: 4,
}

Circuit size: 2^16 with 389 free gates
Gate count:

- RecCircuit: 1 x 51918.0 = 51918
- RecCircuit/MainPodVerify: 1 x 40794.0 = 40794
- RecCircuit/MainPodVerify/PubSt: 48 x 7.2 = 346
- RecCircuit/MainPodVerify/OpVerifyPriv: 48 x 556.7 = 26722
- RecCircuit/MainPodVerify/OpVerifyPriv/OpOpenInputSt: 48 x 11.8 = 566
- RecCircuit/MainPodVerify/OpVerifyPriv/OpCustom: 48 x 94.6 = 4540
- RecCircuit/MainPodVerify/OpVerifyPriv/MerkleDeleteOp: 48 x 15.2 = 729
- RecCircuit/MainPodVerify/OpVerifyPriv/MerkleUpdateOp: 48 x 16.4 = 788
- RecCircuit/MainPodVerify/OpVerifyPriv/MerkleInsertOp: 48 x 17.2 = 826
- RecCircuit/MainPodVerify/OpVerifyPriv/OpSignedBy: 48 x 14.0 = 674
- RecCircuit/MainPodVerify/OpVerifyPriv/OpPublicKeyOf: 48 x 14.0 = 674
- RecCircuit/MainPodVerify/OpVerifyPriv/OpNotContainsFromEntries: 48 x 14.1 = 677
- RecCircuit/MainPodVerify/OpVerifyPriv/OpContainsFromEntries: 48 x 15.7 = 754
- RecCircuit/MainPodVerify/OpVerifyPriv/OpReplaceValueWithEntry: 48 x 27.3 = 1312
- RecCircuit/MainPodVerify/OpVerifyPriv/OpMaxOf: 48 x 19.7 = 947
- RecCircuit/MainPodVerify/OpVerifyPriv/OpProductOf: 48 x 40.6 = 1948
- RecCircuit/MainPodVerify/OpVerifyPriv/OpSumOf: 48 x 21.1 = 1012
- RecCircuit/MainPodVerify/OpVerifyPriv/OpHashOf: 48 x 15.1 = 725
- RecCircuit/MainPodVerify/OpVerifyPriv/OpLtToNeq: 48 x 13.0 = 626
- RecCircuit/MainPodVerify/OpVerifyPriv/OpTransitiveEq: 48 x 17.1 = 819
- RecCircuit/MainPodVerify/OpVerifyPriv/OpLtEqFromEntries: 48 x 24.6 = 1179
- RecCircuit/MainPodVerify/OpVerifyPriv/OpEqNeqFromEntries: 48 x 16.9 = 811
- RecCircuit/MainPodVerify/OpVerifyPriv/OpNone: 48 x 11.6 = 559
- RecCircuit/MainPodVerify/OpVerifyPriv/GetTaggedTblEntry: 48 x 47.2 = 2265
- RecCircuit/MainPodVerify/OpVerifyPriv/ResolveOpArgs: 48 x 87.6 = 4205
- RecCircuit/MainPodVerify/StsMtProof: 20 x 109.8 = 2196
- RecCircuit/MainPodVerify/StsMtProof/MerkleProofStateTransition_10: 20 x 109.8 = 2196
- RecCircuit/MainPodVerify/StsMtProof/MerkleProofStateTransition_10/MerkleProofExist_10: 20 x 35.6 = 712
- RecCircuit/MainPodVerify/StsMtProof/MerkleProofStateTransition_10/MerkleProof_10: 20 x 37.0 = 740
- RecCircuit/MainPodVerify/BuildOpAuxTbl: 1 x 9343.0 = 9343
- RecCircuit/MainPodVerify/BuildOpAuxTbl/SignedBy: 4 x 187.5 = 750
- RecCircuit/MainPodVerify/BuildOpAuxTbl/SignedBy/HashTaggedTblEntry: 4 x 2.0 = 8
- RecCircuit/MainPodVerify/BuildOpAuxTbl/SignedBy/SignatureVerify: 4 x 183.5 = 734
- RecCircuit/MainPodVerify/BuildOpAuxTbl/PublicKeyOf: 2 x 146.5 = 293
- RecCircuit/MainPodVerify/BuildOpAuxTbl/PublicKeyOf/HashTaggedTblEntry: 2 x 2.0 = 4
- RecCircuit/MainPodVerify/BuildOpAuxTbl/CustomPredVerify: 10 x 258.2 = 2582
- RecCircuit/MainPodVerify/BuildOpAuxTbl/CustomPredVerify/HashTaggedTblEntry: 10 x 40.0 = 400
- RecCircuit/MainPodVerify/BuildOpAuxTbl/CustomPredVerify/CustomOpVerify: 10 x 184.2 = 1842
- RecCircuit/MainPodVerify/BuildOpAuxTbl/CustomPredVerify/CustomOpVerify/PredFromTmpl: 50 x 1.2 = 58
- RecCircuit/MainPodVerify/BuildOpAuxTbl/CustomPredVerify/CustomOpVerify/StArgFromTmpl: 50 x 21.8 = 1088
- RecCircuit/MainPodVerify/BuildOpAuxTbl/OpenInputSt: 20 x 51.7 = 1034
- RecCircuit/MainPodVerify/BuildOpAuxTbl/OpenInputSt/HashTaggedTblEntry: 20 x 7.0 = 140
- RecCircuit/MainPodVerify/BuildOpAuxTbl/OpenInputSt/MerkleProofExist_10: 20 x 35.5 = 711
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProofStateTransition_32: 6 x 324.2 = 1945
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProofStateTransition_32/MerkleProofExist_32: 6 x 107.0 = 642
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProofStateTransition_32/MerkleProof_32: 6 x 108.3 = 650
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProofStateTransition_8: 12 x 90.4 = 1085
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProofStateTransition_8/MerkleProofExist_8: 12 x 29.0 = 348
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProofStateTransition_8/MerkleProof_8: 12 x 30.4 = 365
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProof_32: 8 x 108.5 = 868
- RecCircuit/MainPodVerify/BuildOpAuxTbl/MerkleProofExist_8: 22 x 29.0 = 639
- RecCircuit/MainPodVerify/BuildOpAuxTbl/HashTaggedTblEntry: 49 x 3.0 = 145
- RecCircuit/MainPodVerify/BuildCustomPredTbl: 1 x 1847.0 = 1847
- RecCircuit/MainPodVerify/BuildCustomPredTbl/CustomPred: 10 x 184.7 = 1847
- RecCircuit/MainPodVerify/BuildCustomPredTbl/CustomPred/MerkleProof_16: 10 x 56.4 = 564
- RecCircuit/MainPodVerify/VerifyInPod: 2 x 22.5 = 45
- RecCircuit/MainPodVerify/VerifyInPod/MerkleProofExist_6: 2 x 22.5 = 45
- RecCircuit/VerifyProof: 2 x 5443.5 = 10887

@ed255 ed255 marked this pull request as draft May 19, 2026 09:33
@ed255 ed255 marked this pull request as ready for review May 20, 2026 09:09
@ed255 ed255 requested a review from robknight May 20, 2026 09:56
Copy link
Copy Markdown
Collaborator

@robknight robknight left a comment

Choose a reason for hiding this comment

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

Looks good overall!

There's an off-by-one which needs to be fixed but the other issues are less important, and even if they're valid we could choose to tidy them up later.

Comment thread src/frontend/mod.rs Outdated
} else {
0
} + self.statements.iter().filter(|(public, _)| *public).count();
if public_count > 2 << BASE_PARAMS.max_depth_public_statements_mt {
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.

2 << BASE_PARAMS.max_depth_public_statements_mt is 2048, as we're shifting from 2^1 to 2^11. The 2 should be 1, or use 2.pow(BASE_PARAMS.max_depth_public_statements_mt) (I think the latter is slightly easier to read).

This appears in a few other places.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

oh wow, this was bad, thanks for catching this!

Comment thread src/frontend/mod.rs
if self.public_statements.len() > self.params.max_public_statements {

let public_count = if self.extend_input_pod0_public_statements {
self.input_pods[0].public_statements.len()
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.

This will panic if no input POD is provided, which might be reasonable but could also be caught earlier, e.g. in reveal or prove, or we could prevent the flag being set if there's no input POD.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

good point, I'll add a check in the extend_input_pod0_public_statements method, so that if the flag is true, the input pods are not empty.

Comment thread src/middleware/mod.rs Outdated
@@ -930,6 +930,8 @@ pub struct BaseParams {
/// Number of public statements to hash to calculate the public inputs. Must be equal or
/// greater than `max_public_statements`.
pub num_public_statements_hash: 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.

This seems to be unused now.

Comment thread src/backends/plonky2/mainpod/mod.rs Outdated
#[derive(Serialize, Deserialize)]
struct Data {
public_statements: Vec<Statement>,
// public_statements_mt: Array,
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.

This commented line can be removed

let (_, proof) = empty_pod.pub_raw_statements_mt().prove(0)?;
let pad_raw_st = empty_pod.pub_raw_statements()[0].clone();
InputPodOpenStatement {
pod_index: params.max_input_pods - 1,
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.

I think this means "we have no input statements, therefore the penultimate POD must be empty". This makes sense when max_input_pods is 2: even if we import no statements from it, we might be extending POD 0's statement tree, but POD 1 should be empty in a case where we are not importing any statements.

I wonder if it breaks or is insufficient with values other than 2 for max_input_pods.

It's also possible that the user might have added a second input POD without importing any statements from it, and what they will see here is a proof failure rather than an error message at the frontend level.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right, there are a few cases where this would break. I think a better option is the following:

  • if there's a user-defined input pod at index 0, use one of it's statements as padding.
  • otherwise use the statement 0 from the empty pod, which is used as padding and will be at index 0.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

although we could have an input pod with an empty statements tree, then there's nothing to open 😢

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'll add an assert safeguard in the backend MainPod to guarantee that we catch early input pods with empty statements trees (input pods with empty statement trees don't make any sense)

siblings: data.proof_siblings.clone(),
};
verify_merkle_proof_existence_circuit(builder, &proof);
let is_intro = builder.not(pod.is_main);
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.

Previously we used to verify that each statement coming from an intro POD was either None or a blank intro statement, but we don't check that here. I'm not sure that it's exploitable but it does seem like a looser check.

Copy link
Copy Markdown
Collaborator Author

@ed255 ed255 May 20, 2026

Choose a reason for hiding this comment

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

I forgot to document this in the PR message. I've added:

  • Change how we distinguish MainPod from IntroPod. An IntroPod was previously detected by inspecting the first statement and looking for an intro statement with blank verifier data hash. Now this would require an opening, so I've extended the public inputs to include a field that signals when the pod is main. If the pod is main, it's verifier data hash must be in the VDSet; so no intro can lie about it. If it claims to be intro, when we open a statement we validate that it's indeed intro and place its verifier data hash inside.

Now when opening, if the pod was self-declared as intro, the normalization step will do:

  • If statement is None, return None
  • otherwise, replace predicate by Intro(vd_hash)

This means that if an intro pod outputs statements that are not None, and are not Intro(blank) they'll just get replaced by Intro(vd_hash). If an end user doesn't recognize that vd_hash they can treat those statements as garbage.
If the intro pod declares itself as main, then the check that its vd_hash is in the VDSet will fail.

@robknight robknight self-requested a review May 25, 2026 10:11
Comment thread src/middleware/operation.rs Outdated
Self::check_replace_value_with_entry(entries, st_in, st_out)?
}
(Self::OpenInputStatement(data), st) => {
let st_hash = st.hash();
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.

There is a problem with statements from input PODs here. An intro POD's Merkle tree contains hash(raw_statement), because the intro POD can't commit to its own vd_hash.

We can check the statement type by matching on Statement::Intro, but we don't know if we are opening a statement from an intro POD (in which case we get a raw statement) or from a MainPod which is republishing an intro statement (in which case we get a normalized statement).

In my "get episode 1 working" branch I had this code:

  let raw_hash = data.raw_statement.hash();
  let mt_ok = MerkleTree::verify(data.sts_root, &data.proof, &key, &raw_hash.raw()).is_ok();
  let st_matches_raw = match (st, &data.raw_statement) {
      // Intro statements in raw form (EMPTY_HASH in IntroPredicateRef.verifier_data_hash):
      // accept `st` as the normalized version (with the input pod's vd_hash).
      (Statement::Intro(st_ir, st_args), Statement::Intro(raw_ir, raw_args))
          if raw_ir.verifier_data_hash == EMPTY_HASH =>
      {
          st_ir.name == raw_ir.name
              && st_ir.args_len == raw_ir.args_len
              && st_args == raw_args
      }
      // All other cases (chained MainPods storing already-normalized intro statements,
      // non-intro statements) require exact equality.
      (a, b) => a == b,
  };
  mt_ok && st_matches_raw

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for pointing this out, the implementation of the OpenInputStatement check in the middleware was incorrect, as it was not handing the raw statement from an intro input pod correctly. I've fixed it in 85c137d

The circuit version was correct:

let measure = measure_gates_begin!(builder, "OpenInputSt");
let pod = builder.vec_ref_small(params, input_pod_table, data.input_pod_table_index);
let key = ValueTarget::from_int_lo(builder, data.st_index);
let raw_st_hash =
builder.hash_n_to_hash_no_pad::<PoseidonHash>(data.raw_statement.flatten());
let value = ValueTarget {
elements: raw_st_hash.elements,
};
let proof = MerkleProofExistenceTarget {
max_depth: BASE_PARAMS.max_depth_public_statements_mt,
root: pod.sts_root,
key,
value,
siblings: data.proof_siblings.clone(),
};
verify_merkle_proof_existence_circuit(builder, &proof);
let is_intro = builder.not(pod.is_main);
let st = normalize_statement_circuit(
params,
builder,
&data.raw_statement,
is_intro,
&pod.vd_hash,
);

As you can see it uses the raw statement hash to verify the merkle proof, and then it normalizes it to put it into the table that will later be queried in the OpenInput operation.

@ed255
Copy link
Copy Markdown
Collaborator Author

ed255 commented May 25, 2026

I've made kv_hash_target pub so that it can be reused by external implementation of intro pods.

@ed255 ed255 merged commit 277a983 into main May 25, 2026
6 checks passed
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