Repository: ethereum/consensus-specs

Issue:
`bls.AggregatePKs` is undefined in Altair spec
The Altair spec currently uses a function `bls.AggregatePKs` to aggregate a batch of BLS pubkeys in the function `get_sync_committee`.

This function is defined internal to the `pyspec` code but is not specified in the IETF signature standard which, according to the spec, is our source of `bls` functionality.

I'd say it is worth considering defining a helper like `eth2_fast_aggregate_verify` for this routine for the completeness of the spec.


edit: implementation incorporating feedback below:

```python
def eth2_aggregate_pubkeys(pubkeys: Sequence[BLSPubkey]) -> BLSPubkey:
    """
    Return the aggregate public key for the public keys in ``pubkeys``.

    NOTE: the `+` operation should be interpreted as elliptic curve point addition, which takes as input
    elliptic curve points that must be decoded from the input `BLSPubkey`s. 
    This implementation is for demonstrative purposes only and ignores encoding/decoding concerns. 
    Refer to the BLS signature draft standard for more information.
    """
    assert len(pubkeys) > 0
    result = copy(pubkeys[0])
    for pubkey in pubkeys[1:]:
        result += pubkey
    return result
```


Discussion:
If we do so, can we be more explicit about pubkey copies here?

`result = pubkeys[0]` and `result +=` looks like in-place modification of the pubkey, but it shouldn't look like it changes the inputs (even though it doesn't). We could change it to start at 0, or `copy()` it.
It makes sense to me. Maybe add a note/comment that the pseudo-code ignores the encoding?

Backlog: we should add `eth2_fast_aggregate_verify` and `eth2_aggregate_pubkeys` tests to the BLS test vectors.

> could change it to start at 0
> the pseudo-code ignores the encoding

yeah, i would want to avoid making `0x00 * 48` as the initial value to avoid discrepancies w/ encodings...

`copy` seems like a nice way forward and i think adding a note about ignoring encoding issues is a good idea


Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -55,6 +55,12 @@ def floorlog2(x: int) -> uint64:
 '''
 
 
+OPTIMIZED_BLS_AGGREGATE_PUBKEYS = '''
+def eth2_aggregate_pubkeys(pubkeys: Sequence[BLSPubkey]) -> BLSPubkey:
+    return bls.AggregatePKs(pubkeys)
+'''
+
+
 class ProtocolDefinition(NamedTuple):
     # just function definitions currently. May expand with configuration vars in future.
     functions: Dict[str, str]
@@ -305,6 +311,11 @@ def invariant_checks(cls) -> str:
         """
         raise NotImplementedError()
 
+    @classmethod
+    @abstractmethod
+    def implement_optimizations(cls, functions: Dict[str, str]) -> Dict[str, str]:
+        raise NotImplementedError()
+
     @classmethod
     @abstractmethod
     def build_spec(cls, preset_name: str,
@@ -429,6 +440,10 @@ def hardcoded_custom_type_dep_constants(cls) -> Dict[str, str]:
     def invariant_checks(cls) -> str:
         return ''
 
+    @classmethod
+    def implement_optimizations(cls, functions: Dict[str, str]) -> Dict[str, str]:
+        return functions
+
     @classmethod
     def build_spec(cls, preset_name: str,
                    source_files: Sequence[Path], preset_files: Sequence[Path], config_file: Path) -> str:
@@ -482,6 +497,11 @@ def invariant_checks(cls) -> str:
     TIMELY_HEAD_WEIGHT + TIMELY_SOURCE_WEIGHT + TIMELY_TARGET_WEIGHT + SYNC_REWARD_WEIGHT + PROPOSER_WEIGHT
 ) == WEIGHT_DENOMINATOR'''
 
+    @classmethod
+    def implement_optimizations(cls, functions: Dict[str, str]) -> Dict[str, str]:
+        if "eth2_aggregate_pubkeys" in functions:
+            functions["eth2_aggregate_pubkeys"] = OPTIMIZED_BLS_AGGREGATE_PUBKEYS.strip()
+        return super().implement_optimizations(functions)
 
 #
 # MergeSpecBuilder
@@ -588,7 +608,8 @@ def format_protocol(protocol_name: str, protocol_def: ProtocolDefinition) -> str
     for k in list(spec_object.functions):
         if "ceillog2" in k or "floorlog2" in k:
             del spec_object.functions[k]
-    functions_spec = '\n\n\n'.join(spec_object.functions.values())
+    functions = builder.implement_optimizations(spec_object.functions)
+    functions_spec = '\n\n\n'.join(functions.values())
 
     # Access global dict of config vars for runtime configurables
     for name in spec_object.config_vars.keys():
@@ -831,7 +852,7 @@ def initialize_options(self):
         self.out_dir = 'pyspec_output'
         self.build_targets = """
                 minimal:presets/minimal:configs/minimal.yaml
-                mainnet:presets/mainnet:configs/mainnet.yaml        
+                mainnet:presets/mainnet:configs/mainnet.yaml
         """
 
     def finalize_options(self):
@@ -853,6 +874,7 @@ def finalize_options(self):
                     specs/phase0/validator.md
                     specs/phase0/weak-subjectivity.md
                     specs/altair/beacon-chain.md
+                    specs/altair/bls.md
                     specs/altair/fork.md
                     specs/altair/validator.md
                     specs/altair/p2p-interface.md


Test patch: