-
-
Save denniswon/28aa29640e8e9eb6677a6e175a013834 to your computer and use it in GitHub Desktop.
| Here's the full picture: | |
| --- | |
| Newton Privacy Layer — Known Limitations & Next Steps | |
| ★ Insight ───────────────────────────────────── | |
| The limitations fall into three buckets: (1) security gaps where the system doesn't enforce what it should, (2) trust assumptions | |
| where centralization remains, and (3) missing infrastructure that blocks future phases. Each has a clear resolution path — none are | |
| architectural dead ends. | |
| ───────────────────────────────────────────────── | |
| Known Limitations | |
| 1. Challenger Cannot Verify Privacy Tasks (NEWT-664) — SECURITY GAP | |
| Problem: evaluate_and_prove() in crates/challenger/src/lib.rs:171 replays task responses to detect misbehavior, but it has no access | |
| to decrypted privacy data. The Rego evaluation runs without data.identity.*, data.privacy.*, or data.ephemeral.* — so the challenger's | |
| result will differ from the operator's result even when the operator was honest. | |
| Impact: Operators can return arbitrary results for privacy-enabled tasks without risk of slashing. This is the single largest security | |
| gap in the shipped system. | |
| Solution (two-stage): | |
| - Phase 1.5 (short-term): Detect privacy-enabled tasks and skip challenge evaluation + emit challenger_privacy_tasks_skipped metric at | |
| WARN level. Prevents false-positive slashing. | |
| - Phase 2 (long-term): Challenger holds a FROST key share from the DKG ceremony, requests partial decryptions from operators, combines | |
| t-of-n shares, and replays with full privacy context. | |
| Linear: NEWT-664, High priority, Backlog. | |
| --- | |
| 2. Gateway Reconstructs Plaintext (Trust Assumption) | |
| Problem: Even with threshold DKG, the gateway is the entity that combines the partial DH outputs via Lagrange interpolation. It sees | |
| the reconstructed shared secret and decrypts the HPKE ciphertext. This is true for both centralized and threshold modes — the | |
| difference is whether the gateway holds the full key (centralized) or reconstructs it per-task from operator contributions | |
| (threshold). | |
| Impact: A compromised gateway can read all ephemeral privacy data. Identity and confidential data are safer — operators decrypt those | |
| locally. | |
| Solution: Phase 3 MPC — operators evaluate the Rego policy directly on encrypted data without any party seeing plaintext. This | |
| requires: | |
| - NEWT-173 (privacy-preserving policy evaluation with MPC/ZK) | |
| - NEWT-168 (MPC framework — canceled, needs rescoping) | |
| - Possibly FHE or garbled circuits depending on benchmarking (NEWT-631) | |
| Timeline: Phase 3, no concrete date. | |
| --- | |
| 3. No Epoch-Based Key Rotation (Forward Secrecy Gap) | |
| Problem: The FROST DKG ceremony produces a single set of key shares that remain valid indefinitely. If an attacker compromises t | |
| shares across any time window, they can decrypt all data ever encrypted to that key. | |
| Impact: No forward secrecy. A single key compromise has unlimited blast radius. | |
| Solution: PSS (Proactive Secret Sharing) with epoch rotation: | |
| - NEWT-628 (canonical spec, due 2026-05-30): epoch-based rotation, MSK destruction, grace periods | |
| - NEWT-639: EpochRegistry Solidity contract for on-chain epoch lifecycle | |
| - Old shares zeroed via zeroize crate after 2-epoch grace period | |
| - MPK stays constant across epochs (no client re-encryption needed) | |
| Timeline: Phase 2C, target Q2 2026. | |
| --- | |
| 4. No Privacy-Specific Slashing | |
| Problem: Operators who leak plaintext, submit invalid partial decryptions, or miss DKG ceremony rounds face no economic penalty beyond | |
| the general task misbehavior slashing. | |
| Impact: No economic deterrent for privacy-specific misbehavior. | |
| Solution: | |
| - NEWT-629 (slashing conditions, blocked by NEWT-628 epoch context) | |
| - NEWT-640 (PrivacySlasher contract) | |
| - NEWT-641 (DleqVerifier Solidity library for on-chain DLEQ proof verification) | |
| - Slashing conditions: missed partial decryption, invalid DLEQ proof, plaintext leakage attestation | |
| Timeline: Phase 2D, after epoch rotation ships. | |
| --- | |
| 5. No On-Chain Auditability for Threshold Operations | |
| Problem: Threshold decryption happens entirely off-chain. There's no on-chain record of which operators participated, which DLEQ | |
| proofs were submitted, or which epoch's key was used. | |
| Impact: No verifiable audit trail for privacy operations. Disputes rely on off-chain logs. | |
| Solution: NEWT-630 (on-chain aggregation commitments) — post a commitment hash of the threshold operation metadata on-chain after each | |
| decryption. | |
| Timeline: Phase 2C, alongside epoch rotation. | |
| --- | |
| 6. Plaintext in Gateway Memory Not Wrapped with secrecy Crate | |
| Problem: Decrypted ephemeral privacy data exists as plain Vec<u8> / String in gateway memory during the task lifecycle. If the process | |
| dumps core or a debugging tool attaches, plaintext is readable. | |
| Impact: Low — requires host-level access to exploit. But defense-in-depth says wrap it. | |
| Solution: Wrap with secrecy::Secret<Vec<u8>> and zeroize-on-drop. The keystore already uses zeroize::Zeroizing (confirmed in | |
| crates/core/src/dkg/keystore.rs:28). Extend this pattern to ephemeral decrypted data in inline_privacy.rs and sync.rs. | |
| Timeline: Quick win, can be done anytime. | |
| --- | |
| 7. Private Data Storage Still on PostgreSQL | |
| Problem: Operators currently access encrypted data blobs from the gateway's PostgreSQL database. External operators (not co-located | |
| with the gateway) need direct DB access — a security and operational risk. | |
| Impact: Limits operator decentralization. External operators can't participate in identity/confidential data flows without DB | |
| credentials. | |
| Solution: Private Data Storage Layer (decided 2026-03-19 team meeting): | |
| - Phase 1 (done): Postgres stopgap with operator DB access | |
| - Phase 2: HPKE migration (done) | |
| - Phase 3: redb (pure Rust) + Jellyfish Merkle Tree, NATS JetStream sync | |
| - Linear: NEWT-799 through NEWT-813 | |
| Timeline: Separate project track, Q3 2026. | |
| --- | |
| Prioritized Next Steps | |
| ┌──────────┬─────────────────────────────────────────────┬──────────────┬──────────┬────────────────────────────────────────────┐ | |
| │ Priority │ What │ Linear │ Effort │ Why First │ | |
| ├──────────┼─────────────────────────────────────────────┼──────────────┼──────────┼────────────────────────────────────────────┤ | |
| │ 1 │ Challenger privacy skip (NEWT-664 Phase │ NEWT-664 │ 1-2 days │ Security gap — false-positive slashing │ | |
| │ │ 1.5) │ │ │ risk │ | |
| ├──────────┼─────────────────────────────────────────────┼──────────────┼──────────┼────────────────────────────────────────────┤ | |
| │ 2 │ secrecy crate for ephemeral plaintext │ (no ticket) │ 1 day │ Quick defense-in-depth win │ | |
| ├──────────┼─────────────────────────────────────────────┼──────────────┼──────────┼────────────────────────────────────────────┤ | |
| │ 3 │ PSS epoch rotation (NEWT-628) │ NEWT-628 │ 3-4 │ Forward secrecy — longest-pole Phase 2 │ | |
| │ │ │ │ weeks │ item │ | |
| ├──────────┼─────────────────────────────────────────────┼──────────────┼──────────┼────────────────────────────────────────────┤ | |
| │ 4 │ EpochRegistry contract (NEWT-639) │ NEWT-639 │ 1-2 │ On-chain epoch lifecycle, blocks slashing │ | |
| │ │ │ │ weeks │ │ | |
| ├──────────┼─────────────────────────────────────────────┼──────────────┼──────────┼────────────────────────────────────────────┤ | |
| │ 5 │ On-chain aggregation commitments (NEWT-630) │ NEWT-630 │ 1 week │ Auditability for threshold ops │ | |
| ├──────────┼─────────────────────────────────────────────┼──────────────┼──────────┼────────────────────────────────────────────┤ | |
| │ 6 │ PrivacySlasher + DleqVerifier │ NEWT-640, │ 2 weeks │ Economic security for privacy misbehavior │ | |
| │ │ (NEWT-640/641) │ 641 │ │ │ | |
| ├──────────┼─────────────────────────────────────────────┼──────────────┼──────────┼────────────────────────────────────────────┤ | |
| │ 7 │ Privacy slashing conditions (NEWT-629) │ NEWT-629 │ 1 week │ Ties slasher to specific violations │ | |
| └──────────┴─────────────────────────────────────────────┴──────────────┴──────────┴────────────────────────────────────────────┘ | |
| Items 1-2 are quick wins. Items 3-7 form the Phase 2C/2D roadmap and should be executed in order (628 → 639 → 630 → 640/641 → 629). | |
| --- | |
| ★ Insight ───────────────────────────────────── | |
| The system is in a solid position for a pre-mainnet AVS — all three privacy data paths work end-to-end, threshold DKG is operational, | |
| and KMS is fully removed. The remaining gaps are about hardening (forward secrecy, slashing, auditability) rather than fundamental | |
| capability. NEWT-664 is the only gap that affects correctness of the current system — everything else is about reducing trust | |
| assumptions or adding economic security. The Phase 2C/2D work is pure Solidity + coordination protocol — the Rust-side crypto is done. | |
| ───────────────────────────────────────────────── | |
| Want me to create a Linear ticket for the secrecy crate wrapping, or start working on NEWT-664 (challenger privacy skip)? |
⏺ You're right — re-encryption is O(N) per epoch where N grows over time. With thousands of identity records and daily epochs, that's a
meaningful operational burden plus a failure mode (what if re-encryption doesn't complete before the next epoch?).
Decision updated: PSS (constant MPK) from the start. No re-encryption needed.
Let me update everything — Linear, docs, and memory:
Read 1 file (ctrl+o to expand)
⏺ Now let me replace the entire epoch rotation section with the PSS approach:
⏺ Update(docs/THRESHOLD_DKG.md)
⎿ Added 53 lines, removed 41 lines
297 | On-chain verification gas | Expensive without precompiles (EIP-196 is alt_bn128, not Ristretto255). TODO: benchmark
pure-Solidity Ristretto255 verification or consider BN254-based DLEQ alternative |
298 | False positive rate | Negligible (relies on DLP hardness) |
299
300 -## Epoch-Based Key Rotation (Planned — NEWT-628)
300 +## Epoch-Based Key Rotation via PSS (Planned — NEWT-628)
301
302 -Each epoch produces a new set of threshold key shares via FROST DKG ceremony. Epoch transitions provide forward secrec
-y: an adversary who corrupts different t-1 operator subsets in different epochs cannot combine shares across epochs to
- reconstruct the secret.
302 +Proactive Secret Sharing (PSS) refreshes operator key shares each epoch while keeping the Master Public Key (MPK) cons
+tant. Clients encrypt once to the MPK and never need to re-fetch it. No re-encryption of stored data is needed across
+epochs.
303
304 | Parameter | Default | Purpose |
305 |-----------|---------|---------|
306 | Epoch length | ~24h (epoch_duration_seconds = 86400) | Share refresh interval |
307 -| Grace period | min 2 epochs OR re-encryption complete | Old and new shares both valid |
308 -| Retry interval | 30 min | DKG ceremony retry on failure |
307 +| Grace period | 2 epochs (configurable) | Old and new shares both valid |
308 +| Retry interval | 30 min | Refresh retry on failure |
309
310 +### Why PSS (Not Fresh DKG Per Epoch)
311 +
312 +Fresh FROST DKG per epoch changes the MPK, requiring re-encryption of all persistent data (identity + confidential) on
+ every epoch transition. This is O(N) where N grows over time and creates an operational scaling problem: thousands of
+ identity records re-encrypted daily, with a failure mode if re-encryption doesn't complete before the next epoch.
313 +
314 +PSS avoids this entirely. MPK stays constant, so all ciphertext — past and future — is always decryptable with current
+-epoch shares. New operators joining get new shares that work with the same MPK. No data migration, no re-encryption o
+rchestration.
315 +
316 +### PSS Refresh Protocol
317 +
318 +Each epoch, operators run a 1-round refresh protocol (simpler than the 3-round FROST DKG):
319 +
320 +1. Each operator i generates a random refresh polynomial r_i(x) of degree t-1 with r_i(0) = 0 (zero constant term)
321 +2. Operator i computes Feldman VSS commitments C_i = [r_i_0 * G, r_i_1 * G, ...] and broadcasts them
322 +3. Operator i sends r_i(j) to each operator j (encrypted to j's X25519 key)
323 +4. Each operator verifies received refresh shares against commitments
324 +5. Each operator updates: sk'_i = sk_i + Σ_j r_j(i)
325 +
326 +Since every r_j(0) = 0, the sum Σ_j r_j(0) = 0, so the master secret (and MPK) are unchanged. Old shares become un
+correlated with new shares — an adversary who compromises t-1 shares in epoch N and t-1 different shares in epoch N+1
+cannot combine them.
327 +
328 +### Implementation: Building on Existing Primitives
329 +
330 +The PSS refresh reuses existing code from crates/core/src/dkg/:
331 +
332 +| Primitive | Existing Code | PSS Addition |
333 +|-----------|--------------|--------------|
334 +| Polynomial evaluation | dealer.rs (Horner's method) | Constrain constant term to zero |
335 +| Feldman VSS commitments | dealer.rs | Reuse as-is for refresh commitments |
336 +| Share verification | dealer.rs | Reuse as-is |
337 +| Scalar arithmetic | combine.rs (Ristretto255) | Share accumulation: sk'_i = sk_i + Σ r_j(i) |
338 +| Encrypted share distribution | frost.rs (round 1/2/3) | Simplified to 1 round |
339 +
340 +New crypto code is ~65 lines (zero-constant polynomial generation + share/commitment accumulation). The coordination p
+rotocol is ~650 lines (similar to DkgCoordinator but 1 round instead of 3).
341 +
342 ### Design: Graceful Forward Secrecy Degradation
343
312 -Old key shares are never destroyed until the new epoch's DKG ceremony succeeds AND re-encryption of persistent data co
-mpletes. This decouples decryption liveness from epoch transitions:
344 +Old key shares are never destroyed until the new epoch's refresh succeeds AND all operators confirm they hold updated
+shares. This decouples decryption liveness from epoch transitions:
345
346 - Epoch transitions are opportunistic — failures retry with backoff, no user impact
315 -- No freeze, no task rejection, no centralized fallback on DKG failure
316 -- Forward secrecy window grows when ceremonies fail (visible via metrics)
317 -- Grace period begins only after the new epoch succeeds, not on a timer
347 +- No freeze, no task rejection, no centralized fallback on refresh failure
348 +- Forward secrecy window grows when refreshes fail (visible via metrics)
349 +- Grace period begins only after the refresh succeeds, not on a timer
350
351 All three privacy paths (identity, confidential, ephemeral) support both centralized and threshold decryption modes un
iformly. The paths differ in data ownership and persistence, not decryption mode.
352
...
354
355 1. Time-based (default: 24 hours)
356 2. Operator set change (add/remove operator)
325 -3. Admin/governance emergency rotation (newt_dkg_initiate)
357 +3. Admin/governance emergency rotation
358
327 -### Re-encryption of Persistent Data
359 +### Operator Set Changes
360
329 -MPK changes each epoch. Persistent data (identity + confidential) encrypted under old MPK must be re-encrypted to the
-new MPK during the grace period. Without re-encryption, new operators cannot decrypt old data, and operator set change
-s over time would cause threshold failures for old epoch data.
361 +When the operator set changes (add/remove), a full FROST DKG ceremony runs (not a PSS refresh) to produce shares for t
+he new set under the same MPK. This is the only case where a full DKG is needed after the initial ceremony. PSS refres
+h handles the common case (same operator set, periodic rotation).
362
331 -The gateway orchestrates re-encryption as a background batch during the grace period:
332 -
333 -1. Query encrypted_data_refs where data_type IN ('identity', 'confidential') and not expired
334 -2. For each record: collect partial DH from old-epoch operators, Lagrange combine, decrypt
335 -3. Re-encrypt plaintext to new epoch MPK (plaintext zeroed via Zeroizing<Vec<u8>>)
336 -4. Update encrypted_data_refs with new ciphertext
337 -
338 -Unlinked records (identity unlinked, confidential grant revoked) are still re-encrypted — unlinking is reversible and
-the data can be relinked before expiration. Expired records are skipped during re-encryption.
339 -
340 -Ephemeral inline data (data.privacy.inline[0].*) is unaffected — it is per-task and never stored.
341 -
363 ### Expired Data Handling
364
365 Encrypted data is never deleted. Expired records remain in the database for audit trail and potential recovery.
366
346 -- Re-encryption: expired records are skipped (no point re-encrypting unusable data). After old epoch shares are ze
-roed, expired records encrypted under old MPK become permanently undecryptable — this is intentional.
367 - Task evaluation: if an operator resolves identity or confidential data that has expired, the resolution fails an
d the policy evaluation is rejected. Expired data cannot be used in policy decisions.
348 -- Metric: privacy_reencryption_skipped_expired — count of expired records skipped per epoch transition.
368 +- No re-encryption needed: since MPK is constant, all stored ciphertext remains decryptable with current-epoch sha
+res regardless of age.
369
370 ### Grace Period Lifecycle
371
372 353 -Epoch N+1 DKG succeeds 354 - |-- Grace period begins 355 - |-- Re-encryption starts (background batch) 356 - |-- Re-encryption completes (metric: privacy_reencryption_complete) 357 - |-- Grace period ends: min 2 epochs AND re-encryption done, whichever later 358 - |-- Epoch N shares zeroed 373 +Epoch N refresh succeeds 374 + |-- Grace period begins (operators hold old + new shares) 375 + |-- Grace period ends after 2 epochs (configurable) 376 + |-- Epoch N shares zeroed via zeroize crate 377
378
361 -After re-encryption, new operators can decrypt all persistent data and departed operators' shares become irrelevant. D
-eregistered operators' shares remain usable during grace as a fallback — the gateway prefers active operators when col
-lecting partial decryptions. Newton's permissioned operator set means deregistration is coordinated with the Newton Fo
-undation.
379 +Deregistered operators' shares remain usable during grace as a fallback — the gateway prefers active operators when co
+llecting partial decryptions. Newton's permissioned operator set means deregistration is coordinated with the Newton F
+oundation.
380
381 ### Failure Handling
382
365 -When a DKG ceremony fails (operators offline, network partition), the current epoch remains fully functional. The gate
-way retries every 30 minutes. The only scenario where decryption breaks is operational key loss (disk failure) combine
-d with insufficient threshold — handled by manual newt_dkg_initiate as recovery.
383 +When a refresh fails (operators offline, network partition), the current epoch remains fully functional. The gateway r
+etries every 30 minutes. The only scenario where decryption breaks is operational key loss (disk failure) combined wit
+h insufficient threshold — handled by manual newt_dkg_initiate as recovery.
384
385 ### Keystore
386
369 -One keystore file per epoch (threshold_keystore_epoch_{N}.json). ceremony_id maps to epoch_id. Old epoch files a
-re deleted after grace period + re-encryption completion.
387 +One keystore file per epoch (threshold_keystore_epoch_{N}.json). ceremony_id maps to epoch_id. Old epoch files a
+re deleted after grace period completion.
388
389 ### Metrics
390
391 - privacy_current_epoch — active epoch number
392 - privacy_epoch_age_seconds — time since last successful rotation
375 -- privacy_epoch_rotation_attempts — ceremony attempts since last success
393 +- privacy_epoch_rotation_attempts — refresh attempts since last success
394 - privacy_epoch_rotation_overdue — alert when age exceeds 2x target interval
377 -- privacy_reencryption_progress — persistent envelopes re-encrypted / total
378 -- privacy_reencryption_complete — boolean per epoch transition
395
396 ### EpochRegistry Contract
397
382 -New contract: EpochRegistry stores full epoch metadata on-chain (MPK, operator set hash, threshold, start/end timest
-amps), handles rotation triggers, and records share destruction attestations. Clients can verify the current epoch's M
-PK without trusting the gateway's RPC. Tracked in NEWT-639.
398 +New contract: EpochRegistry stores full epoch metadata on-chain (MPK, operator set hash, threshold, start/end timest
+amps), handles rotation triggers, and records share destruction attestations. Since MPK is constant, EpochRegistry r
+ecords the same MPK for every epoch — the changing fields are operator set hash and timestamps. Clients can verify the
+ MPK on-chain without trusting the gateway's RPC. Tracked in NEWT-639.
399
384 -### PSS (Constant MPK) — Deferred to Phase 3
385 -
386 -Proactive Secret Sharing would keep MPK constant across epochs, eliminating the need for re-encryption. No audited Rus
-t PSS crate exists for Ristretto255. The fresh FROST DKG approach is a drop-in replacement if PSS is later justified b
-y enterprise constant-MPK requirements.
387 -
400 ## Privacy-Specific Slashing (Planned — NEWT-629)
401
402 None of these slashing mechanisms are implemented yet. The current challenger (ChallengeVerifier.raiseAndResolveChall enge()) only handles policy evaluation mismatches via ZK proofs. Privacy-specific slashing requires a new PrivacySla sher contract and on-chain DLEQ/quorum attestation infrastructure. Tracked in [NEWT-629](https://linear.app/magiclabs
/issue/NEWT-629).
⏺ Now update NEWT-628 on Linear, memory systems, and the PRIVACY.md limitations section:
⏺ Update(docs/PRIVACY.md)
⎿ Added 1 line, removed 1 line
669 - Gateway trust: In threshold mode, the gateway combines partial DH outputs and sees plaintext during task process
ing. In centralized mode, operators decrypt locally — the gateway never sees plaintext. Phase 3 (MPC) removes the gate
way as a trust point.
670 - No onchain authorization record: Authorization is validated offchain by the Gateway. There is no onchain audit t
rail of decryption events. Tracked in NEWT-630.
671 - Plaintext in memory: Raw decryption output is wrapped with zeroize::Zeroizing<Vec<u8>> so bytes are zeroed on
drop. JSON-parsed copies are not zeroized (serde allocates internally).
672 -- No forward secrecy: Key shares from the initial DKG ceremony remain valid indefinitely. A compromised key share
-has unlimited blast radius. Epoch-based key rotation (NEWT-628) provide
-s forward secrecy by refreshing shares periodically. Designed for graceful degradation: old shares live until the next
- epoch succeeds, so DKG failures never block privacy operations — only the forward secrecy window grows.
672 +- No forward secrecy: Key shares from the initial DKG ceremony remain valid indefinitely. A compromised key share
+has unlimited blast radius. Epoch-based key rotation via Proactive Secret Sharing ([NEWT-628](https://linear.app/magic
+labs/issue/NEWT-628)) provides forward secrecy by refreshing shares periodically while keeping MPK constant — no re-en
+cryption of stored data needed. Old shares live until the next epoch succeeds, so refresh failures never block privacy
+ operations.
673 - No privacy-specific slashing: Operators who leak plaintext or submit invalid partial decryptions face no economi
c penalty. Tracked in NEWT-629.
674
675 ---
⏺ PR #517 now has 8 commits. All systems updated:
- Linear: NEWT-628 updated with PSS design, NEWT-664 Done
- Docs: THRESHOLD_DKG.md rewritten for PSS, PRIVACY.md limitations updated, RPC_API.md fixed
- Memory: All 3 systems (local, supermemory, recall) have the revised PSS decision
The epoch rotation design is now complete and consistent across all documentation. The key architectural choices:
┌─────────────────────┬─────────────────────────────────────────┐
│ Decision │ Choice │
├─────────────────────┼─────────────────────────────────────────┤
│ Refresh protocol │ PSS (constant MPK, 1-round) │
├─────────────────────┼─────────────────────────────────────────┤
│ Operator set change │ Full FROST DKG (same MPK) │
├─────────────────────┼─────────────────────────────────────────┤
│ Re-encryption │ Not needed (MPK constant) │
├─────────────────────┼─────────────────────────────────────────┤
│ Failure handling │ Graceful degradation (no freeze) │
├─────────────────────┼─────────────────────────────────────────┤
│ Data deletion │ Never (expired = rejected at task time) │
├─────────────────────┼─────────────────────────────────────────┤
│ Epoch timing │ Time-based (24h default) │
├─────────────────────┼─────────────────────────────────────────┤
│ Grace period │ 2 epochs, fixed │
└─────────────────────┴─────────────────────────────────────────┘
Ready to wrap up this session, or anything else?
✻ Worked for 3m 22s
❯ so is reencryption fully not needed and removed from our docs/Linear?
⏺ Good question — let me verify there are no lingering re-encryption references:
Searched for 1 pattern (ctrl+o to expand)
⏺ Found one stale reference. Line 665 in PRIVACY.md still says "expired records skipped during re-encryption" — re-encryption no longer
exists with PSS. Let me fix it:
⏺ Update(docs/PRIVACY.md)
⎿ Added 1 line, removed 1 line
662 | Context rebinding | AAD binds ciphertext to specific policy_client + chain_id |
663 | Replay of encrypted data | Ephemeral data is per-task (not stored); persistent data uses UUID + TTL expiration |
664 | Gateway key compromise | Phase 2 (threshold decryption) distributes trust across operator set |
665 -| Data persistence | TTL-based expiration; expired records skipped during re-encryption and rejected at task t
-ime (encrypted data never deleted) |
665 +| Data persistence | TTL-based expiration; expired records rejected at task evaluation time (encrypted data ne
+ver deleted) |
666
667 ### Limitations
668
⏺ The THRESHOLD_DKG.md references to re-encryption (lines 302, 312, 314) are correct — they explain why re-encryption was rejected in
favor of PSS. Those are "why not" explanations, not describing current behavior.
Let me also check the THRESHOLD_DKG.md expired data section for any re-encryption mention:
Searched for 1 pattern (ctrl+o to expand)
⏺ All good — every re-encryption reference in THRESHOLD_DKG.md is either "why we rejected it" (lines 312-314) or "not needed with PSS"
(lines 302, 368). These are correct as-is.
Now commit and push:
⏺ Bash(git add docs/PRIVACY.md && git commit -m "$(cat <<'EOF'
docs: remove stale re-encryption reference from PRIVACY.md threat table…)
⎿ [refactor/privacy-namespace-zeroize ef6cf169] docs: remove stale re-encryption reference from PRIVACY.md threat table
1 file changed, 1 insertion(+), 1 deletion(-)
remote:
… +6 lines (ctrl+o to expand)
⏺ PR #517 now has 9 commits. Re-encryption is fully removed from the design — zero stale references remain. With PSS, the data lifecycle
is simple:
- Encrypt once to the constant MPK
- Never re-encrypt (MPK doesn't change)
- Never delete (expired records stay for audit, rejected at task time)
- Shares refresh each epoch via PSS (1-round protocol, MPK unchanged)
Anything else, or ready to wrap?
✻ Worked for 1m 45s
⏺ Done. The make dkg command is now on PR #517. Usage:
Initiate DKG ceremony (2 operators, local)
make dkg command=initiate total=2
Initiate on stagef (auto-resolves gateway URL)
make dkg command=initiate total=2 env=stagef
Check status
make dkg command=status env=stagef
Cancel
make dkg command=cancel env=stagef
Custom gateway URL + threshold override
make dkg command=initiate total=3 threshold=2 gateway_url=https://custom-gateway.example.com
Ready to proceed to NEWT-628 implementation, or anything else first?
✻ Sautéed for 4m 20s
❯ how do I specific which chain?
⏺ Good catch — the current script doesn't support chain selection. The DKG ceremony is gateway-wide (the gateway serves multiple
chains), but the newt_dkg_initiate request doesn't take a chain ID because threshold keys are shared across all chains the gateway
serves.
However, in a multichain setup with separate gateway instances per network, you'd pick the gateway by environment. Let me clarify:
Is the question about:
(A) Selecting which gateway instance to talk to (e.g., Sepolia gateway vs Base gateway) — this is already handled by env=stagef which
resolves to the right gateway URL
(B) Running DKG for a specific chain's operator set within a single multi-chain gateway — the current DKG is gateway-wide (one
threshold context for the entire gateway), not per-chain
Which case are you thinking about? Or do we need per-chain DKG support (e.g., different threshold configs for different chains)?
❯ we have 1 gateway that is cross chain, so that is okay. operators set at the moment is also shared (same keys) across different
chains
⏺ Then the current design is correct — DKG is gateway-wide, not per-chain. The env param (local/stagef/prod) selects which gateway to
talk to, and the threshold context applies to all chains that gateway serves. No chain parameter needed.
Ready to proceed to NEWT-628 (PSS epoch rotation implementation)?
❯ how do I test this locally for anvil running gateway/operators?
⏺ Good question. You'd use the existing make gateway frost=true which starts the gateway devnet with FROST DKG + privacy + two-phase
enabled:
Terminal 1: Start gateway devnet with FROST DKG support
make gateway frost=true
Terminal 2: Initiate DKG ceremony (2 operators on local anvil)
make dkg command=initiate total=2
Check status
make dkg command=status
Cancel if needed
make dkg command=cancel
The env=local default points to http://127.0.0.1:8080 and uses the test API key automatically. The make gateway frost=true target
starts anvil, deploys contracts, registers 2 operators, and runs the gateway with the frost-dkg feature enabled.
Let me verify the gateway devnet target actually starts with the right port and features:
Searched for 1 pattern (ctrl+o to expand)
⏺ The make gateway frost=true enables PRIVACY_ENABLED=true and TWO_PHASE_CONSENSUS=true automatically. The gateway runs on port 8080 by
default. So the workflow is:
# Terminal 1: Start gateway with FROST DKG (anvil + deploy + 2 operators)
make gateway frost=true
# Terminal 2: Once gateway is ready, initiate DKG
make dkg command=initiate total=2
# The script auto-polls status every 2 seconds until Complete or Failed
# Expected output:
# === DKG Initiate ===
# Environment: local
# Gateway: http://127.0.0.1:8080/rpc
# Threshold: 2-of-2
# Ceremony ID: dkg-1744236000-a1b2c3d4
# Round timeout: 60s
#
# Polling ceremony status...
# [5/120] Status: Round1
# [10/120] Status: Round2
# [15/120] Status: Round3
# [17/120] Status: Complete
#
# DKG ceremony completed successfully.
# MPK: <hex-encoded X25519 public key>
That's it — no extra setup beyond what make gateway frost=true already provides. Ready to move on to NEWT-628?
One question before we write the implementation plan:
The design says "operator set changes trigger a full FROST DKG ceremony (not PSS refresh) to produce shares for the new set under the
same MPK." But a standard FROST DKG ceremony produces a new random MPK — it doesn't preserve the existing one. To keep the same MPK
when the operator set changes, we'd need a resharing protocol (an existing holder runs a t-of-n dealing to redistribute shares of the
existing secret to the new set).
This is different from both PSS refresh (same set, same secret) and fresh DKG (new set, new secret). The options:
(A) Resharing via existing holders — t current operators cooperate to create new shares for the new set under the same MPK. Requires a
custom protocol (similar complexity to PSS refresh). MPK stays constant.
(B) Fresh FROST DKG — new MPK when operator set changes. Simpler but breaks the constant-MPK guarantee for this case only. All
previously encrypted data still works (old shares kept during grace), but new data encrypted to new MPK means the SDK must re-fetch.
(C) Defer operator set change handling — since the operator set is permissioned and rarely changes, defer this to a follow-up ticket.
Ship PSS refresh (same operator set) first, handle operator changes manually with newt_dkg_initiate for now.
My recommendation: (C) — ship the common case (periodic PSS refresh, same operator set) first. Operator set changes are rare in a
permissioned set and already handled by manual newt_dkg_initiate. The resharing protocol adds significant complexity for a case that
happens maybe once a quarter.
What's your take?
❯ so PSS doesn't hold when the operator set changes? Also, if we do newt_dkg_initiate, do we need to "reencrypt" all the previous
encrypted data?
⏺ Correct on both points. Let me be precise:
PSS only works with the same operator set. The refresh polynomial r_i(x) is generated by each existing operator i — if operator i
doesn't exist (new joiner) or has left (departed), they can't participate in the refresh. PSS fundamentally assumes the same n
participants.
If we run newt_dkg_initiate for a new operator set, it produces a new MPK. And yes — all previously encrypted data is tied to the old
MPK. To decrypt it, you need old key shares. This brings us back to two options:
- Keep old shares around forever for old-MPK data → defeats forward secrecy, accumulates key material
- Re-encrypt all persistent data from old MPK to new MPK during the transition → the O(N) scaling problem we rejected
So the constant-MPK guarantee holds only as long as the operator set doesn't change. When it does, we're back to the same problem.
★ Insight ─────────────────────────────────────
This is why the secret_sharing_and_dkg crate's resharing protocol matters. Resharing is the mechanism that lets you change the
operator set while preserving the same secret (and MPK). It's distinct from both PSS (same set refresh) and DKG (new set, new secret).
Without resharing, "constant MPK" only holds for the steady state — any operator set change breaks it.
─────────────────────────────────────────────────
The options are now clearer:
┌────────────────────────────────────────────┬────────────────────┬──────┬───────────────────────┐
│ Scenario │ Protocol │ MPK │ Re-encryption needed? │
├────────────────────────────────────────────┼────────────────────┼──────┼───────────────────────┤
│ Same operators, periodic rotation │ PSS refresh │ Same │ No │
├────────────────────────────────────────────┼────────────────────┼──────┼───────────────────────┤
│ Operator added/removed (with resharing) │ Resharing protocol │ Same │ No │
├────────────────────────────────────────────┼────────────────────┼──────┼───────────────────────┤
│ Operator added/removed (without resharing) │ Fresh FROST DKG │ New │ Yes │
└────────────────────────────────────────────┴────────────────────┴──────┴───────────────────────┘
For a permissioned 2-operator set that rarely changes, (C) defer resharing is practical — operator changes are coordinated and
infrequent. But if you want the constant-MPK guarantee to hold even across operator set changes, we need resharing.
How do you want to handle this? Options:
(C) Defer resharing — ship PSS refresh now, manual fresh DKG + re-encryption for the rare operator change
(D) Include resharing in NEWT-628 — build the resharing protocol alongside PSS refresh so the full constant-MPK story is complete
❯ how difficult is it to implement resharing? is the technology tested and good for use in production?
⏺ Let me give you an honest assessment.
Resharing: How It Works
Resharing is conceptually simple — it's a dealing ceremony where t existing holders redistribute their combined secret to a new set:
Old set: operators {A, B} hold shares of secret S (threshold 2-of-2)
New set: operators {A, B, C} should hold shares of same S (threshold 2-of-3)
Protocol:
- t=2 old operators (A, B) cooperate to act as a "virtual dealer"
- Each old operator generates a sub-sharing polynomial for their share
- New operators combine the sub-shares to get their new share of S
- Verify commitments → new shares are valid for the same MPK
Implementation Complexity
┌─────────────────────────┬─────────────────────────────────┬──────────────────────────────────────────┐
│ Component │ PSS Refresh (already planned) │ Resharing (additional) │
├─────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────┤
│ Polynomial generation │ Zero-constant term │ Standard (non-zero constant = old share) │
├─────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────┤
│ Share distribution │ Same set → direct send │ Old set → new set (different addressing) │
├─────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────┤
│ Commitment verification │ Same as Feldman VSS │ Same as Feldman VSS │
├─────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────┤
│ Coordinator protocol │ 1 round │ 1 round (same structure) │
├─────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────┤
│ New crypto code │ ~65 lines │ ~40 additional lines │
├─────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────┤
│ Coordinator changes │ Minimal — reuse PSS coordinator │ ~100 lines — handle old→new set mapping │
└─────────────────────────┴─────────────────────────────────┴──────────────────────────────────────────┘
The resharing protocol is structurally almost identical to PSS refresh. The only difference is:
- PSS: each operator deals with r_i(0) = 0 (zero constant)
- Resharing: each operator deals with r_i(0) = sk_i (their current share as constant)
The same polynomial evaluation, Feldman commitments, and verification code work for both.
Production Readiness
┌─────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Aspect │ Assessment │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Academic foundation │ Well-established — Desmedt & Jajodia (1997), Frankel et al. (1997). Same age as PSS. │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Rust crates │ No standalone production crate. Same situation as PSS — we'd build on our own dealer.rs primitives. │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Security properties │ Proven: new shares are independent of old shares, MPK unchanged, verifiable via commitments │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Audit scope │ Small delta from PSS — same polynomial + Feldman VSS math, just different constant term │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Production usage │ Threshold Network (tBTC) uses resharing for their guardian set changes. Not exotic. │
└─────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────┘
Honest Bottom Line
If we're already building PSS refresh (~65 lines new crypto), resharing adds ~40 more lines of the same kind of code (polynomial
dealing with a non-zero constant). The coordinator needs ~100 lines extra to handle old-set→new-set addressing. The crypto audit scope
barely increases since it's the same mathematical structure.
The hard part of both PSS and resharing is the coordinator protocol (operator discovery, encrypted share exchange, timeout handling,
status tracking) — and that code is shared between them.
My revised recommendation: include resharing in NEWT-628. The marginal cost is small (~140 lines over what PSS already requires), and
it completes the constant-MPK guarantee. Without it, PSS gives you a promise that breaks the moment you add operator C.
What do you think?
─────────────────────────────────────────────────────────────────────────────────
Spec written. Let me do a quick self-review and then present it to you.
★ Insight ─────────────────────────────────────
The key architectural insight in this design: PSS refresh and resharing are the same mathematical operation with one difference — the
constant term of the dealing polynomial. PSS uses r_i(0) = 0 (preserves secret, same set), resharing uses r_i(0) = sk_i (redistributes
secret, new set). The coordinator protocol, commitment verification, and encrypted share distribution are identical for both. This
means ~80% of the code is shared.
─────────────────────────────────────────────────
Spec self-review checklist:
- Placeholder scan: No TBDs, TODOs, or incomplete sections
- Internal consistency: PSS and resharing protocols consistent with the coordinator state machine
- Scope check: Focused on epoch rotation only — EpochRegistry contract (NEWT-639) explicitly deferred to separate PR
- Ambiguity check: One potential ambiguity — resharing "under the same MPK" when operator set changes
Actually, I need to flag one thing: the resharing section says "needs t old operators to participate" but what if we have 2-of-2 and
one operator is being removed? Then only 1 old operator remains, which is below threshold. This is an edge case:
- Adding an operator (2-of-2 → 2-of-3): both old operators participate in resharing. Works fine.
- Removing an operator (2-of-2 → 1-of-1): impossible — FROST requires t >= 2.
- Removing 1 from 3 (2-of-3 → 2-of-2): any 2 of the 3 old operators participate. Works fine.
- Removing 1 from 2 (2-of-2 → only 1 left): can't form a valid threshold set. Must add a replacement first.
For a permissioned set, this is managed operationally: add the replacement before removing the departing operator. I'll note this in
the spec.
Spec written to docs/superpowers/specs/2026-04-09-pss-epoch-rotation-design.md. Please review it and let me know if you want to make
any changes before we start writing out the implementation plan.
❯ we will at minimum always have 2 operators
⏺ Good — that simplifies the edge case. With a minimum of 2 operators guaranteed:
- Adding: 2-of-2 → 2-of-3 (both old participate in resharing) — works
- Replacing: add new first, reshare to 3, then remove old (2-of-3 → 2-of-2) — works
- Never go below 2 — FROST t >= 2 constraint always met
No spec changes needed for this — the constraint is operational and already enforced by Newton Foundation's permissioned set
management.
The spec is ready for your review at docs/superpowers/specs/2026-04-09-pss-epoch-rotation-design.md. Any changes, or shall I proceed
to the implementation plan?
❯ One potential ambiguity — resharing "under the same MPK" when operator set changes --> what is this?
⏺ The ambiguity I flagged is about what "same MPK" means during resharing. Let me clarify:
When you run a resharing protocol, the goal is that the new operator set ends up holding shares of the same secret S that the old set
held. Since MPK = S * G (scalar times base point), the MPK stays the same.
But there's a subtle question: how do we verify that the resharing produced shares of the same secret, not a different one?
The answer is: the Feldman commitments prove it. During resharing, each old operator i publishes commitment C_i_0 = sk_i * G (their
public share). Anyone can verify that the Lagrange combination of these public shares equals the known MPK:
sum(lambda_i * C_i_0) = sum(lambda_i * sk_i * G) = S * G = MPK
If this check passes, the resharing is guaranteed to preserve the same secret. If any old operator tries to cheat (use a wrong
constant term), the commitment check fails.
This isn't a design gap — it's a verification step that's already implied by Feldman VSS. I was being overly cautious when I flagged
it. The spec is correct as written — the math guarantees constant MPK through the commitment verification that's already part of the
protocol.
No changes needed. Ready to proceed to the implementation plan?
⏺ Plan saved to docs/superpowers/plans/2026-04-09-pss-epoch-rotation.md.
Plan Summary
9 tasks across 3 PRs:
┌───────────────────────┬──────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│ PR │ Tasks │ What ships │
├───────────────────────┼──────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ PR 1 (core │ Tasks │ refresh.rs (PSS + resharing crypto), EpochMetadata/EpochConfig types, multi-epoch keystore, │
│ primitives) │ 1-4 │ metrics definitions │
├───────────────────────┼──────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ PR 2 (gateway + │ Tasks │ RefreshCoordinator (1-round protocol), operator refresh/apply RPC handlers, EpochManager │
│ operator) │ 5-7 │ (timer loop + retry) │
├───────────────────────┼──────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ PR 3 (E2E) │ Tasks │ make pss-refresh-e2e, full lifecycle test │
│ │ 8-9 │ │
└───────────────────────┴──────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘
Task 1 has complete, compilable code with 4 unit tests (refresh polynomial zero-constant, refresh preserves secret 2-of-3, refresh
share verification, reshare 2-of-2 → 2-of-3, reshare 2-of-3 → 2-of-2). Tasks 2-4 are small type/config additions. Tasks 5-7 are the
coordination layer. Tasks 8-9 are the E2E test.
Two execution options:
- Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration
- Inline Execution — Execute tasks in this session, batch execution with checkpoints
Which approach?
Here's the current state of the Newton Privacy Layer:
Current Status
┌──────────────────────────────┬────────────────────┬──────────────────────────────────────────────────────┐
│ Phase │ Status │ Key Deliverables │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 1: Foundation │ COMPLETE │ HPKE, Ed25519, SecureEnvelope, SDK v0.5.4 │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 1.5: Gateway-Mediated │ COMPLETE │ Gateway decryption, challenger privacy (PR #516) │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 1.5B: KMS → HPKE │ COMPLETE, DEPLOYED │ AWS KMS removed from all 5 repos │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 2 DKG core │ MERGED │ FROST DKG, Feldman VSS, keystore, ArcSwap hot-reload │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 2C: PSS epoch rotation │ IN PROGRESS │ PRs #523/#525/#526 open (~1,300 lines, 26 tests) │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 2D: Privacy slashing │ Not started │ Blocked by NEWT-628 │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 3: MPC/ZK │ Not started │ 7 backlog tickets │
├──────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ Phase 4: Ecosystem │ Not started │ 11 backlog tickets │
└──────────────────────────────┴────────────────────┴──────────────────────────────────────────────────────┘
What's Implemented in Phase 2C (PRs open, not merged)
- PSS refresh + resharing crypto primitives (refresh.rs)
- EpochMetadata, EpochConfig types
- Multi-epoch keystore (write/list/read/delete per epoch)
- RefreshCoordinator state machine (1-round protocol)
- Operator newt_dkgRefreshRound + newt_dkgRefreshApply RPC handlers
- EpochManager background task (timer + retry)
- 4 Prometheus metrics
- E2E crypto smoke test (make pss-refresh-e2e)
Known Limitations
- Coordinator HTTP wiring not complete
Impact: The RefreshCoordinator.run_refresh() has a TODO for HTTP fan-out to operators. The state machine, types, and operator handlers
are ready — the missing piece is the gateway driving the protocol end-to-end (collect from operators → broadcast → apply).
Next step: Wire run_refresh() with the operator pool and HTTP client from GatewayServiceImpl, following the same pattern as
DkgCoordinator.run_ceremony().
- No EpochRegistry contract (on-chain)
Impact: Epoch metadata (MPK, operator set hash, timestamps) exists only in-memory and keystores. No on-chain verifiability.
Next step: NEWT-639 — Solidity contract + deployer integration. Can be built in parallel since it's independent of the Rust-side work.
- No privacy-specific slashing
Impact: Operators who submit invalid partial decryptions, miss DKG rounds, or leak plaintext face no economic penalty beyond general
task misbehavior slashing.
Next step: NEWT-629 (slashing conditions), NEWT-640 (PrivacySlasher contract), NEWT-641 (DleqVerifier library). All blocked by
NEWT-628 completion.
- Gateway reconstructs plaintext in threshold mode
Impact: In threshold mode, the gateway combines partial DH outputs and sees the full plaintext during task processing. A compromised
gateway can read all ephemeral privacy data.
Next step: Phase 3 — MPC computation on encrypted data without any party seeing plaintext.
- No forward secrecy until epoch rotation is operational
Impact: Key shares from the initial DKG ceremony remain valid indefinitely. A compromised share has unlimited blast radius.
Next step: Complete the coordinator HTTP wiring in NEWT-628 to enable automatic 24h epoch rotation.
- No operator set change automation
Impact: When operators are added/removed, the resharing protocol code exists but isn't triggered automatically. Currently requires
manual newt_dkg_initiate.
Next step: Wire operator set change detection in EpochManager (via chain watcher events or operator pool change callbacks) to trigger
resharing automatically.
Prioritized Next Steps
┌──────────┬─────────────────────────────────────────────────┬──────────────────────┬───────────┐
│ Priority │ What │ Ticket │ Effort │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┼───────────┤
│ 1 │ Wire RefreshCoordinator HTTP calls to operators │ NEWT-628 (remaining) │ 2-3 days │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┼───────────┤
│ 2 │ Full protocol E2E test (gateway-driven refresh) │ NEWT-628 (remaining) │ 1-2 days │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┼───────────┤
│ 3 │ EpochRegistry Solidity contract │ NEWT-639 │ 1-2 weeks │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┼───────────┤
│ 4 │ On-chain aggregation commitments │ NEWT-630 │ 1 week │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┼───────────┤
│ 5 │ Privacy-specific slashing conditions │ NEWT-629 │ 1 week │
├──────────┼─────────────────────────────────────────────────┼──────────────────────┼───────────┤
│ 6 │ PrivacySlasher + DleqVerifier contracts │ NEWT-640/641 │ 2 weeks │
└──────────┴─────────────────────────────────────────────────┴──────────────────────┴───────────┘
Items 1-2 complete NEWT-628. Items 3-6 are the remaining Phase 2 tickets. After those, Phase 2 is done and the system has forward
secrecy, on-chain auditability, and economic security for privacy operations.
Project: Newton Privacy Layer
Date: 2026-04-14
Summary: Completed PSS epoch rotation (NEWT-628) — all 3 stacked PRs (#523, #525, #526) merged to main after addressing reviewer feedback
from 0xkr8os. Addressed 12 review comments across the stack (7 on #523, 5 on #525, 4 on #526), fixing: module extraction, field name
consistency, feature gating, RPC helper dedup, BFT threshold formula, and slither CI config. Also merged gateway-never-decrypts (#533)
and multi-domain resolution (#534). Privacy Layer Phase 2C Rust code is now complete.
Implementation Progress:
- PR #523 MERGED: PSS core primitives, types, keystore, metrics
- PR #525 MERGED: RefreshCoordinator state machine, operator handlers, EpochManager
- PR #526 MERGED: E2E test, coordinator HTTP wiring, zero TODOs
- PR #522 MERGED: make dkg command
- PR #533 MERGED: gateway-never-decrypts
- PR #534 MERGED: multi-domain resolution + threshold for all privacy types
Key Review Feedback Applied:
- Extracted refresh_types + handlers from core.rs (2235 LOC) to crates/operator/src/refresh.rs
- Renamed RefreshRoundResponse.shares → encrypted_shares for consistency with gateway
- Gated refresh, refresh_coordinator, epoch_manager modules behind frost-dkg feature
- Consolidated call_operator_rpc and sign_dkg_message (removed duplicates in refresh_coordinator)
- Changed run_refresh to take &Arc<Mutex> instead of owned Arc
- Fixed epoch threshold: max(2, ceil(2n/3)) instead of unanimous
- Fixed slither CI: comma-separated strings instead of JSON arrays
Learnings:
- self: &Arc<Mutex> method receiver requires nightly arbitrary_self_types — use &Arc as regular param instead
- Feature gating must cascade: if module A imports gated module B, A must also be gated (or use cfg-conditional imports)
- Slither 0.10.4+ changed config from JSON arrays to comma-separated strings — unpinned CI tools break silently
Remaining Work (Phase 2 Solidity):
- NEWT-639: EpochRegistry contract (backlog, unblocked)
- NEWT-629: Privacy slashing conditions (backlog, unblocked by NEWT-628)
- NEWT-640: PrivacySlasher contract (backlog)
- NEWT-641: DleqVerifier library (backlog)
- NEWT-630: On-chain aggregation commitments (backlog)
Current State: All Privacy Layer Rust code (Phases 1→2C) is complete and merged. Remaining work is exclusively Solidity contracts for
on-chain epoch registry and privacy-specific slashing.
Good context gathered. Now let me start the grill.
Question 1 of N: Proxy vs Non-Proxy Deployment
The existing registries (IdentityRegistry, ConfidentialDataRegistry, PolicyClientRegistry) all use TransparentUpgradeableProxy with
OwnableUpgradeable. However, BatchTaskManager is deployed without a proxy (lessons.md: "not proxied — redeploy is the only upgrade
path").
For EpochRegistry, the decision matters because:
- Proxied: upgradeable, keeps address stable across versions, but adds gas overhead and storage layout constraints
- Non-proxied: simpler, cheaper gas, but requires redeployment + re-authorization to upgrade
The EpochRegistry stores epoch history (MPK, operator set hashes, destruction attestations). If we redeploy, we lose that history unless
we migrate it.
My recommendation: Proxy (TransparentUpgradeableProxy). The epoch history is valuable for auditability and slashing disputes — losing it
on upgrade is unacceptable. The PrivacySlasher (NEWT-640) will need to reference historical epochs to verify slashing claims. Same
pattern as the other registries.
Do you agree with proxied deployment, or should this be non-proxied like BatchTaskManager?
✻ Brewed for 2m 33s
❯ agree
⏺ Question 2 of N: Who can call registerEpoch?
The spec says "Gateway/aggregator only (task generator role)." In practice, the EpochManager in the gateway drives PSS refresh and would
call registerEpoch after a successful ceremony. Let me check who calls what in the existing pattern:
- IdentityRegistry.registerIdentityData — requires gateway EIP-712 co-signature, verified via
operatorRegistry.isTaskGenerator(recoveredSigner) - ConfidentialDataRegistry.publishData — permissionless (any registered provider)
- NewtonProverTaskManager.createTask — checks isTaskGenerator(msg.sender)
For EpochRegistry, the caller is the gateway's EpochManager, which signs transactions with task_generator_signer (same ECDSA key used for
task creation and DKG ceremonies).
Two options:
- A) require(operatorRegistry.isTaskGenerator(msg.sender)) — simple, direct msg.sender check. Same pattern as createTask. Gateway calls
directly. - B) EIP-712 signature verification — gateway signs off-chain, anyone can relay. More complex but enables gasless submission by a
relayer.
My recommendation: Option A — direct isTaskGenerator(msg.sender). Epoch registration is a low-frequency operation (once per epoch,
default 24h). There's no need for relayer patterns. The gateway already has the signer key and submits on-chain transactions for task
responses. Keeps the contract simpler and cheaper.
A or B?
❯ A
⏺ Question 3 of N: Should EpochRegistry take operatorRegistry as a constructor immutable or as an initializer parameter?
Looking at the existing pattern:
- IdentityRegistry takes operatorRegistry and policyClientRegistry as immutable constructor parameters (set at implementation deploy
time, not in initialize()) - ConfidentialDataRegistry takes policyClientRegistry as an immutable constructor parameter
- These are immutable because the registry addresses don't change — if they did, you'd redeploy the implementation and upgrade the proxy
For EpochRegistry, we need operatorRegistry to call isTaskGenerator(msg.sender). The operator registry address is stable per chain
(deployed once, upgraded in place).
My recommendation: Immutable constructor parameter, matching IdentityRegistry/ConfidentialDataRegistry pattern:
contract EpochRegistry is Initializable, OwnableUpgradeable {
IOperatorRegistry public immutable operatorRegistry;
constructor(IOperatorRegistry _operatorRegistry) {
operatorRegistry = _operatorRegistry;
_disableInitializers();
}
function initialize(address owner) external initializer {
__Ownable_init(owner);
}
}
Agree?
❯ yes
⏺ Question 4 of N: What fields belong in EpochInfo and what's the on-chain storage model?
The spec proposes:
struct EpochInfo {
uint64 epochId;
bytes32 mpk; // Master Public Key (X25519, 32 bytes)
bytes32 operatorSetHash; // keccak256 of participating operator addresses
uint8 threshold; // t in t-of-n
uint8 committeeSize; // n
uint256 startBlock;
uint256 endBlock; // 0 if current epoch
uint256 dkgCeremonyBlock;
}
A few design questions nested here:
MPK format: The Rust side uses Edwards Y (compressed, 32 bytes) which converts to X25519 for HPKE. Storing bytes32 is fine — consumers
interpret it. But should we store the Edwards point or the X25519 point? The PrivacySlasher (NEWT-640) will need to verify DLEQ proofs
against operator public shares, which use Edwards. Clients encrypt to X25519.
My recommendation: Store Edwards Y (bytes32). The X25519 conversion is deterministic and can be done off-chain. DLEQ verification
(NEWT-641) needs the Edwards form. Add a comment clarifying the encoding.
endBlock: The spec uses 0 for "current epoch" and sets it when a new epoch registers. But this means registerEpoch for epoch N+1 must
also write to epoch N's storage slot (to set endBlock). That's an extra SSTORE (~5k gas for warm slot).
My recommendation: Drop endBlock. The end of epoch N is implicitly startBlock of epoch N+1. One less storage write, and isEpochActive can
compute it from the next epoch's startBlock. Store a currentEpochId state variable instead — getEpoch(currentEpochId) gives the active
epoch.
dkgCeremonyBlock: When is this useful? The DKG/PSS ceremony happens off-chain. This block number would be the block at which
registerEpoch was called, which is just startBlock. Seems redundant.
My recommendation: Drop dkgCeremonyBlock. If we need ceremony provenance, the transaction hash of registerEpoch is sufficient.
Proposed trimmed struct:
struct EpochInfo {
uint64 epochId;
bytes32 mpk; // Compressed Edwards Y of threshold master public key
bytes32 operatorSetHash; // keccak256(abi.encodePacked(sorted operator addresses))
uint8 threshold; // t in t-of-n
uint8 committeeSize; // n
uint256 startBlock; // block.number at registration
}
Plus state: uint64 public currentEpochId;
Agree with trimming endBlock and dkgCeremonyBlock?
❯ agree
⏺ Question 5 of N: Grace period — block-based or time-based?
The spec says "configurable duration (default: 600 blocks ~2h)" and the Rust side uses grace_period_epochs: u32 (number of past epochs to
keep, default 2).
These are two different models:
- A) Block-based grace: epoch N enters grace period when epoch N+1 registers, expires after gracePeriodBlocks blocks. Requires
isEpochActive to check block.number < epochs[epochId].startBlock + gracePeriodBlocks. But block times vary per chain (12s L1, 2s L2) —
600 blocks is ~2h on L1 but ~20min on L2. - B) Epoch-count grace: epoch N is active if currentEpochId - epochId <= gracePeriodEpochs. Simple integer comparison. Chain-agnostic.
Matches the Rust side's grace_period_epochs config. - C) Timestamp-based grace: use block.timestamp instead of block.number. Chain-agnostic but block.timestamp is manipulable by validators
(within bounds).
My recommendation: Option B — epoch-count grace. Reasons:
- Matches the Rust side's existing model (grace_period_epochs: 2)
- Chain-agnostic — no time-normalization needed per chain
- Simplest check: currentEpochId - epochId <= gracePeriodEpochs
- The PrivacySlasher needs to know "is this epoch still valid for slashing?" — epoch-count is the cleanest predicate
- Store gracePeriodEpochs as a configurable uint8 (owner-settable)
isEpochActive(epochId) becomes:
function isEpochActive(uint64 epochId) external view returns (bool) {
if (epochId > currentEpochId || epochs[epochId].startBlock == 0) return false;
return currentEpochId - epochId <= gracePeriodEpochs;
}
A, B, or C?
❯ B
⏺ Question 6 of N: Destruction attestations — do we need them on-chain?
The spec includes recordDestructionAttestation(epochId, operator, signature) — operators attest that they've destroyed their old epoch
key shares. This is a forward-secrecy signal: "I no longer hold shares for epoch N."
But thinking through the threat model:
- Honest operator: destroys shares after grace period, may or may not bother submitting an on-chain attestation (costs gas)
- Malicious operator: retains shares regardless — an on-chain attestation proving destruction is unforgeable in theory, but an operator
can sign the attestation and still keep the shares. There's no cryptographic proof of deletion. - Verification: nobody can verify that shares were actually destroyed. The attestation is a trust signal, not a proof.
Each attestation is one SSTORE per operator per epoch. With 20 operators and epoch-count grace of 2, that's 20 transactions every ~24h
just for attestations that can't be verified.
Three options:
- A) Keep on-chain attestations: trust signal for compliance/audit, even if unverifiable. PrivacySlasher could require attestation before
releasing operator from epoch liability. - B) Off-chain attestations only: operators sign attestations, gateway stores them. Available via RPC for auditors. Zero gas cost.
- C) Drop entirely: old shares are overwritten by PSS refresh (the accumulate_refresh replaces the share in memory + keystore). The
protocol already enforces rotation — explicit destruction attestation adds ceremony without security.
My recommendation: Option C — drop destruction attestations. PSS refresh inherently replaces shares. The keystore delete_epoch_keystore
already removes old files after grace period. Adding unverifiable on-chain attestations is compliance theater that costs gas without
improving security. If auditors need proof of rotation, the EpochRegistered events form a chain of epoch transitions — that's the
verifiable signal.
A, B, or C?
❯ C
⏺ Question 7 of N: Emergency rotation — what should it actually do?
The spec includes triggerEmergencyRotation() (admin only). The question is what this does on-chain vs off-chain:
The actual PSS refresh ceremony is entirely off-chain (gateway coordinates operators via RPC). The on-chain EpochRegistry is a passive
record — it doesn't trigger ceremonies, it records their results.
Option A) On-chain signal only: triggerEmergencyRotation() emits an EmergencyRotationTriggered event and sets a boolean
emergencyRotationRequested. The gateway's EpochManager watches for this event and triggers an immediate refresh, bypassing the timer.
After the refresh completes, gateway calls registerEpoch as normal.
Option B) On-chain invalidation: triggerEmergencyRotation() immediately marks the current epoch as expired (sets currentEpochId to 0 or a
sentinel). isEpochActive returns false for everything. This forces all privacy tasks to fail until a new epoch is registered — a hard
kill switch.
Option C) Drop it: emergency rotation is an operational concern, not a contract concern. The admin can just call the gateway's
newt_dkg_initiate RPC to start a new ceremony, or restart the EpochManager with a shorter interval. No on-chain action needed.
My recommendation: Option A — on-chain signal. Reasons:
- It's a lightweight event + flag (~20k gas), not a complex state change
- Gives the admin a single transaction to trigger rotation from a Safe multisig, without needing gateway RPC access
- The gateway already watches on-chain events (chain watcher pattern) — watching EmergencyRotationTriggered is natural
- Doesn't break active privacy tasks (unlike Option B which is a DoS on the system)
- Gateway clears the flag by registering the next epoch
bool public emergencyRotationRequested;
function triggerEmergencyRotation() external onlyOwner {
emergencyRotationRequested = true;
emit EmergencyRotationTriggered(currentEpochId, msg.sender);
}
Gateway clears it in registerEpoch:
if (emergencyRotationRequested) {
emergencyRotationRequested = false;
}
A, B, or C?
❯ A.
⏺ Question 8 of N: Should registerEpoch enforce monotonicity and duplicate prevention?
When the gateway calls registerEpoch, we need to prevent:
- Duplicate registration: same epochId registered twice (replay or bug)
- Out-of-order registration: epoch 5 registered before epoch 4
- Gap skipping: epoch 3 registered after epoch 1 (skipping 2)
My recommendation: Enforce strict monotonicity — epochId == currentEpochId + 1 (or epochId == 0 for the first epoch). This is a single
check that prevents all three issues:
function registerEpoch(EpochInfo calldata info) external {
require(operatorRegistry.isTaskGenerator(msg.sender), NotTaskGenerator());
if (currentEpochId == 0 && epochs[0].startBlock == 0) {
// First epoch registration
require(info.epochId == 0, InvalidEpochId());
} else {
require(info.epochId == currentEpochId + 1, InvalidEpochId());
}
epochs[info.epochId] = info;
currentEpochId = info.epochId;
if (emergencyRotationRequested) {
emergencyRotationRequested = false;
}
emit EpochRegistered(info.epochId, info.mpk, info.threshold, info.committeeSize);
}
One subtlety: should the first epoch be epochId == 0 or epochId == 1? The Rust side uses current_epoch + 1 starting from 0, so the first
real epoch after initial DKG would be epoch 1. But the initial DKG ceremony itself could be epoch 0.
My recommendation: First epoch is 0. The initial FROST DKG produces epoch 0. PSS refresh produces epoch 1, 2, etc. This matches
EpochManager.current_epoch starting at 0.
Agree with strict monotonicity and epoch-0 start?
❯ yes
⏺ Question 9 of N: Cross-chain deployment — source only, or source + destination?
The existing registries have a clear pattern:
- IdentityRegistry and ConfidentialDataRegistry are deployed on all chains (source + destination) because operators on dest chains need
to call them - OperatorRegistry lives on the source chain only — dest chains get operator state via the transporter
For EpochRegistry, the consumers are:
- Gateway — calls registerEpoch after PSS refresh (one gateway, serves all chains)
- PrivacySlasher (future, NEWT-640) — needs isEpochActive and getEpoch to verify slashing claims
- Operators — don't call the contract directly (they get epoch state from the gateway via RPC)
The gateway submits transactions to the source chain (where isTaskGenerator is enforced). The PrivacySlasher would also live on the
source chain (where EigenLayer staking and slashing infrastructure exists).
Option A) Source chain only: EpochRegistry deployed alongside OperatorRegistry on the source chain. PrivacySlasher on source chain can
read it directly. Dest chains don't need epoch state — they verify BLS certificates via BN254CertificateVerifier, not epoch checks.
Option B) Source + destination: Deploy on all chains via NewtonCrossChainDeploymentLib. Dest chain PrivacySlashers could reference local
epoch state. But we don't have dest chain slashers planned — slashing goes through the source chain.
My recommendation: Option A — source chain only. No consumer on destination chains needs epoch data. The PrivacySlasher (NEWT-640) and
DleqVerifier (NEWT-641) will live on the source chain. If a future dest-chain slasher needs epoch data, we can add cross-chain relay at
that point. Avoids unnecessary deployment complexity and transporter sync overhead.
A or B?
❯ A
⏺ Question 10 of N: operatorSetHash computation — who computes it and what's the canonical encoding?
The operatorSetHash is keccak256 of the participating operators. This needs to be deterministic so the PrivacySlasher can recompute it
from an operator list and verify it matches what's stored.
Two sub-questions:
╭─── Claude Code v2.1.105 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │ Recent activity │
│ Welcome back Dennis! │ 12s ago (session) │
│ │ 1m ago rebase to pulled main branch │
│ │ 3h ago (session) │
│ ▐▛███▜▌ │ /resume for more │
│ ▝▜█████▛▘ │ ────────────────────────────────────────────────────────────────────────────────── │
│ ▘▘ ▝▝ │ What's new │
│ │ Added path parameter to the EnterWorktree tool to switch into an existing wor… │
│ Opus 4.6 (1M context) · API Usage Billing · Dennis │ Added PreCompact hook support: hooks can now block compaction by exiting with cod… │
│ Won │ Added background monitor support for plugins via a top-level monitors manifest … │
│ ~/projects/newton-prover-avs │ /release-notes for more │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
⚠ Large .claude/rules/lessons.md will impact performance (47.3k chars > 40.0k) · /memory to edit
⏺ Now update the phase status table and remaining sections:
Wrote 1 memory (ctrl+o to expand)
⏺ Now update MEMORY.md index:
Searched for 1 pattern, recalled 1 memory, searched memories (ctrl+o to expand)
⏺ Now update the MEMORY.md Privacy Layer section:
Wrote 1 memory (ctrl+o to expand)
⏺ Now save to supermemory:
⏺ Bash(node "$HOME/.claude/plugins/cache/supermemory-plugins/claude-supermemory/0.0.2/scripts/save-project-memory.cjs"
"[SAVE:dennis:2026-04-14]…)
⎿ Project knowledge saved: newton-prover-avs
ID: gev1hXQFoWB24ZSgT22Tsa
⏺ Now save to recall MCP:
⏺ Recall MCP connection is down — this happens intermittently with the Redis-backed service. Local memory and supermemory saves were
successful. Let me confirm the summary.
Save complete (2 of 3 systems):
┌──────────────┬─────────┬───────────────────────────────────────────────────────────────────────────┐
│ System │ Status │ Details │
├──────────────┼─────────┼───────────────────────────────────────────────────────────────────────────┤
│ Local memory │ Updated │ privacy-layer.md — phase table, merged PRs, remaining work, new learnings │
├──────────────┼─────────┼───────────────────────────────────────────────────────────────────────────┤
│ Supermemory │ Saved │ ID gev1hXQFoWB24ZSgT22Tsa — team-visible progress update │
├──────────────┼─────────┼───────────────────────────────────────────────────────────────────────────┤
│ Recall MCP │ Skipped │ Connection closed — can retry next session │
└──────────────┴─────────┴───────────────────────────────────────────────────────────────────────────┘
Key changes in local memory:
- Phase 2C status: IN PROGRESS → MERGED
- Gateway-never-decrypts + multi-domain: IN PROGRESS → MERGED
- All 6 PRs moved from "Open PRs" to "Merged PRs" table
- NEWT-628 moved from "Remaining Work" to "Completed Tickets"
- Added 4 new learnings (Arc receiver, cascading gates, slither config, RPC helper dedup)
- Bold callout: "All Privacy Layer Rust code complete — remaining work is Solidity only"
✻ Churned for 2m 55s
❯ Now let's move on to the next steps for Newton Privacy Layer
⏺ ★ Insight ─────────────────────────────────────
With all Rust code merged (Phases 1→2C), the Privacy Layer transitions from cryptographic infrastructure to on-chain enforcement. The
remaining work is Solidity contracts that make epoch rotation and privacy slashing verifiable and enforceable on-chain — closing the
trust loop.
─────────────────────────────────────────────────
Based on the remaining backlog, here are the 5 tickets ordered by dependency and priority:
┌──────────┬──────────┬────────────────────────────────────┬──────────────────┐
│ Priority │ Ticket │ Title │ Blocked by │
├──────────┼──────────┼────────────────────────────────────┼──────────────────┤
│ 1 │ NEWT-639 │ EpochRegistry contract │ None — ready now │
├──────────┼──────────┼────────────────────────────────────┼──────────────────┤
│ 2 │ NEWT-630 │ On-chain aggregation commitments │ None — ready now │
├──────────┼──────────┼────────────────────────────────────┼──────────────────┤
│ 3 │ NEWT-629 │ Privacy slashing conditions (spec) │ None — ready now │
├──────────┼──────────┼────────────────────────────────────┼──────────────────┤
│ 4 │ NEWT-640 │ PrivacySlasher contract │ NEWT-629 │
├──────────┼──────────┼────────────────────────────────────┼──────────────────┤
│ 5 │ NEWT-641 │ DleqVerifier library │ NEWT-629 │
└──────────┴──────────┴────────────────────────────────────┴──────────────────┘
NEWT-639 (EpochRegistry) is the natural next step — it's the on-chain counterpart to the PSS infrastructure we just merged. The
EpochManager already writes epoch metadata locally; this contract makes it verifiable on-chain (MPK, operator set hash, threshold,
timestamps).
NEWT-629 + NEWT-640 + NEWT-641 form the slashing subsystem — the DleqVerifier library feeds into the PrivacySlasher contract, which
implements the slashing conditions spec.
Which ticket do you want to start with? Or would you prefer to do a /grill-me on the EpochRegistry contract design first?
✻ Churned for 31s
❯ yes /grill-me for EpochRegistry for NEWT-639
⏺ Skill(grill-me)
⎿ Successfully loaded skill
⏺ Let me first gather context on the existing contract patterns and the NEWT-639 spec before starting the grill.
Called linear-server (ctrl+o to expand)
⏺ Now let me check existing contract patterns — proxy setup, access control, and similar registries we already have.
⏺ Explore(Explore EpochRegistry contract context)
⎿ Done (33 tool uses · 80.4k tokens · 1m 45s)
(ctrl+o to expand)
⏺ Good context gathered. Now let me start the grill.
Question 1 of N: Proxy vs Non-Proxy Deployment
The existing registries (IdentityRegistry, ConfidentialDataRegistry, PolicyClientRegistry) all use TransparentUpgradeableProxy with
OwnableUpgradeable. However, BatchTaskManager is deployed without a proxy (lessons.md: "not proxied — redeploy is the only upgrade
path").
For EpochRegistry, the decision matters because:
- Proxied: upgradeable, keeps address stable across versions, but adds gas overhead and storage layout constraints
- Non-proxied: simpler, cheaper gas, but requires redeployment + re-authorization to upgrade
The EpochRegistry stores epoch history (MPK, operator set hashes, destruction attestations). If we redeploy, we lose that history unless
we migrate it.
My recommendation: Proxy (TransparentUpgradeableProxy). The epoch history is valuable for auditability and slashing disputes — losing it
on upgrade is unacceptable. The PrivacySlasher (NEWT-640) will need to reference historical epochs to verify slashing claims. Same
pattern as the other registries.
Do you agree with proxied deployment, or should this be non-proxied like BatchTaskManager?
✻ Brewed for 2m 33s
❯ agree
⏺ Question 2 of N: Who can call registerEpoch?
The spec says "Gateway/aggregator only (task generator role)." In practice, the EpochManager in the gateway drives PSS refresh and would
call registerEpoch after a successful ceremony. Let me check who calls what in the existing pattern:
- IdentityRegistry.registerIdentityData — requires gateway EIP-712 co-signature, verified via
operatorRegistry.isTaskGenerator(recoveredSigner) - ConfidentialDataRegistry.publishData — permissionless (any registered provider)
- NewtonProverTaskManager.createTask — checks isTaskGenerator(msg.sender)
For EpochRegistry, the caller is the gateway's EpochManager, which signs transactions with task_generator_signer (same ECDSA key used for
task creation and DKG ceremonies).
Two options:
- A) require(operatorRegistry.isTaskGenerator(msg.sender)) — simple, direct msg.sender check. Same pattern as createTask. Gateway calls
directly. - B) EIP-712 signature verification — gateway signs off-chain, anyone can relay. More complex but enables gasless submission by a
relayer.
My recommendation: Option A — direct isTaskGenerator(msg.sender). Epoch registration is a low-frequency operation (once per epoch,
default 24h). There's no need for relayer patterns. The gateway already has the signer key and submits on-chain transactions for task
responses. Keeps the contract simpler and cheaper.
A or B?
❯ A
⏺ Question 3 of N: Should EpochRegistry take operatorRegistry as a constructor immutable or as an initializer parameter?
Looking at the existing pattern:
- IdentityRegistry takes operatorRegistry and policyClientRegistry as immutable constructor parameters (set at implementation deploy
time, not in initialize()) - ConfidentialDataRegistry takes policyClientRegistry as an immutable constructor parameter
- These are immutable because the registry addresses don't change — if they did, you'd redeploy the implementation and upgrade the proxy
For EpochRegistry, we need operatorRegistry to call isTaskGenerator(msg.sender). The operator registry address is stable per chain
(deployed once, upgraded in place).
My recommendation: Immutable constructor parameter, matching IdentityRegistry/ConfidentialDataRegistry pattern:
contract EpochRegistry is Initializable, OwnableUpgradeable {
IOperatorRegistry public immutable operatorRegistry;
constructor(IOperatorRegistry _operatorRegistry) {
operatorRegistry = _operatorRegistry;
_disableInitializers();
}
function initialize(address owner) external initializer {
__Ownable_init(owner);
}
}
Agree?
❯ yes
⏺ Question 4 of N: What fields belong in EpochInfo and what's the on-chain storage model?
The spec proposes:
struct EpochInfo {
uint64 epochId;
bytes32 mpk; // Master Public Key (X25519, 32 bytes)
bytes32 operatorSetHash; // keccak256 of participating operator addresses
uint8 threshold; // t in t-of-n
uint8 committeeSize; // n
uint256 startBlock;
uint256 endBlock; // 0 if current epoch
uint256 dkgCeremonyBlock;
}
A few design questions nested here:
MPK format: The Rust side uses Edwards Y (compressed, 32 bytes) which converts to X25519 for HPKE. Storing bytes32 is fine — consumers
interpret it. But should we store the Edwards point or the X25519 point? The PrivacySlasher (NEWT-640) will need to verify DLEQ proofs
against operator public shares, which use Edwards. Clients encrypt to X25519.
My recommendation: Store Edwards Y (bytes32). The X25519 conversion is deterministic and can be done off-chain. DLEQ verification
(NEWT-641) needs the Edwards form. Add a comment clarifying the encoding.
endBlock: The spec uses 0 for "current epoch" and sets it when a new epoch registers. But this means registerEpoch for epoch N+1 must
also write to epoch N's storage slot (to set endBlock). That's an extra SSTORE (~5k gas for warm slot).
My recommendation: Drop endBlock. The end of epoch N is implicitly startBlock of epoch N+1. One less storage write, and isEpochActive can
compute it from the next epoch's startBlock. Store a currentEpochId state variable instead — getEpoch(currentEpochId) gives the active
epoch.
dkgCeremonyBlock: When is this useful? The DKG/PSS ceremony happens off-chain. This block number would be the block at which
registerEpoch was called, which is just startBlock. Seems redundant.
My recommendation: Drop dkgCeremonyBlock. If we need ceremony provenance, the transaction hash of registerEpoch is sufficient.
Proposed trimmed struct:
struct EpochInfo {
uint64 epochId;
bytes32 mpk; // Compressed Edwards Y of threshold master public key
bytes32 operatorSetHash; // keccak256(abi.encodePacked(sorted operator addresses))
uint8 threshold; // t in t-of-n
uint8 committeeSize; // n
uint256 startBlock; // block.number at registration
}
Plus state: uint64 public currentEpochId;
Agree with trimming endBlock and dkgCeremonyBlock?
❯ agree
⏺ Question 5 of N: Grace period — block-based or time-based?
The spec says "configurable duration (default: 600 blocks ~2h)" and the Rust side uses grace_period_epochs: u32 (number of past epochs to
keep, default 2).
These are two different models:
- A) Block-based grace: epoch N enters grace period when epoch N+1 registers, expires after gracePeriodBlocks blocks. Requires
isEpochActive to check block.number < epochs[epochId].startBlock + gracePeriodBlocks. But block times vary per chain (12s L1, 2s L2) —
600 blocks is ~2h on L1 but ~20min on L2. - B) Epoch-count grace: epoch N is active if currentEpochId - epochId <= gracePeriodEpochs. Simple integer comparison. Chain-agnostic.
Matches the Rust side's grace_period_epochs config. - C) Timestamp-based grace: use block.timestamp instead of block.number. Chain-agnostic but block.timestamp is manipulable by validators
(within bounds).
My recommendation: Option B — epoch-count grace. Reasons:
- Matches the Rust side's existing model (grace_period_epochs: 2)
- Chain-agnostic — no time-normalization needed per chain
- Simplest check: currentEpochId - epochId <= gracePeriodEpochs
- The PrivacySlasher needs to know "is this epoch still valid for slashing?" — epoch-count is the cleanest predicate
- Store gracePeriodEpochs as a configurable uint8 (owner-settable)
isEpochActive(epochId) becomes:
function isEpochActive(uint64 epochId) external view returns (bool) {
if (epochId > currentEpochId || epochs[epochId].startBlock == 0) return false;
return currentEpochId - epochId <= gracePeriodEpochs;
}
A, B, or C?
❯ B
⏺ Question 6 of N: Destruction attestations — do we need them on-chain?
The spec includes recordDestructionAttestation(epochId, operator, signature) — operators attest that they've destroyed their old epoch
key shares. This is a forward-secrecy signal: "I no longer hold shares for epoch N."
But thinking through the threat model:
- Honest operator: destroys shares after grace period, may or may not bother submitting an on-chain attestation (costs gas)
- Malicious operator: retains shares regardless — an on-chain attestation proving destruction is unforgeable in theory, but an operator
can sign the attestation and still keep the shares. There's no cryptographic proof of deletion. - Verification: nobody can verify that shares were actually destroyed. The attestation is a trust signal, not a proof.
Each attestation is one SSTORE per operator per epoch. With 20 operators and epoch-count grace of 2, that's 20 transactions every ~24h
just for attestations that can't be verified.
Three options:
- A) Keep on-chain attestations: trust signal for compliance/audit, even if unverifiable. PrivacySlasher could require attestation before
releasing operator from epoch liability. - B) Off-chain attestations only: operators sign attestations, gateway stores them. Available via RPC for auditors. Zero gas cost.
- C) Drop entirely: old shares are overwritten by PSS refresh (the accumulate_refresh replaces the share in memory + keystore). The
protocol already enforces rotation — explicit destruction attestation adds ceremony without security.
My recommendation: Option C — drop destruction attestations. PSS refresh inherently replaces shares. The keystore delete_epoch_keystore
already removes old files after grace period. Adding unverifiable on-chain attestations is compliance theater that costs gas without
improving security. If auditors need proof of rotation, the EpochRegistered events form a chain of epoch transitions — that's the
verifiable signal.
A, B, or C?
❯ C
⏺ Question 7 of N: Emergency rotation — what should it actually do?
The spec includes triggerEmergencyRotation() (admin only). The question is what this does on-chain vs off-chain:
The actual PSS refresh ceremony is entirely off-chain (gateway coordinates operators via RPC). The on-chain EpochRegistry is a passive
record — it doesn't trigger ceremonies, it records their results.
Option A) On-chain signal only: triggerEmergencyRotation() emits an EmergencyRotationTriggered event and sets a boolean
emergencyRotationRequested. The gateway's EpochManager watches for this event and triggers an immediate refresh, bypassing the timer.
After the refresh completes, gateway calls registerEpoch as normal.
Option B) On-chain invalidation: triggerEmergencyRotation() immediately marks the current epoch as expired (sets currentEpochId to 0 or a
sentinel). isEpochActive returns false for everything. This forces all privacy tasks to fail until a new epoch is registered — a hard
kill switch.
Option C) Drop it: emergency rotation is an operational concern, not a contract concern. The admin can just call the gateway's
newt_dkg_initiate RPC to start a new ceremony, or restart the EpochManager with a shorter interval. No on-chain action needed.
My recommendation: Option A — on-chain signal. Reasons:
- It's a lightweight event + flag (~20k gas), not a complex state change
- Gives the admin a single transaction to trigger rotation from a Safe multisig, without needing gateway RPC access
- The gateway already watches on-chain events (chain watcher pattern) — watching EmergencyRotationTriggered is natural
- Doesn't break active privacy tasks (unlike Option B which is a DoS on the system)
- Gateway clears the flag by registering the next epoch
bool public emergencyRotationRequested;
function triggerEmergencyRotation() external onlyOwner {
emergencyRotationRequested = true;
emit EmergencyRotationTriggered(currentEpochId, msg.sender);
}
Gateway clears it in registerEpoch:
if (emergencyRotationRequested) {
emergencyRotationRequested = false;
}
A, B, or C?
❯ A.
⏺ Question 8 of N: Should registerEpoch enforce monotonicity and duplicate prevention?
When the gateway calls registerEpoch, we need to prevent:
- Duplicate registration: same epochId registered twice (replay or bug)
- Out-of-order registration: epoch 5 registered before epoch 4
- Gap skipping: epoch 3 registered after epoch 1 (skipping 2)
My recommendation: Enforce strict monotonicity — epochId == currentEpochId + 1 (or epochId == 0 for the first epoch). This is a single
check that prevents all three issues:
function registerEpoch(EpochInfo calldata info) external {
require(operatorRegistry.isTaskGenerator(msg.sender), NotTaskGenerator());
if (currentEpochId == 0 && epochs[0].startBlock == 0) {
// First epoch registration
require(info.epochId == 0, InvalidEpochId());
} else {
require(info.epochId == currentEpochId + 1, InvalidEpochId());
}
epochs[info.epochId] = info;
currentEpochId = info.epochId;
if (emergencyRotationRequested) {
emergencyRotationRequested = false;
}
emit EpochRegistered(info.epochId, info.mpk, info.threshold, info.committeeSize);
}
One subtlety: should the first epoch be epochId == 0 or epochId == 1? The Rust side uses current_epoch + 1 starting from 0, so the first
real epoch after initial DKG would be epoch 1. But the initial DKG ceremony itself could be epoch 0.
My recommendation: First epoch is 0. The initial FROST DKG produces epoch 0. PSS refresh produces epoch 1, 2, etc. This matches
EpochManager.current_epoch starting at 0.
Agree with strict monotonicity and epoch-0 start?
❯ yes
⏺ Question 9 of N: Cross-chain deployment — source only, or source + destination?
The existing registries have a clear pattern:
- IdentityRegistry and ConfidentialDataRegistry are deployed on all chains (source + destination) because operators on dest chains need
to call them - OperatorRegistry lives on the source chain only — dest chains get operator state via the transporter
For EpochRegistry, the consumers are:
- Gateway — calls registerEpoch after PSS refresh (one gateway, serves all chains)
- PrivacySlasher (future, NEWT-640) — needs isEpochActive and getEpoch to verify slashing claims
- Operators — don't call the contract directly (they get epoch state from the gateway via RPC)
The gateway submits transactions to the source chain (where isTaskGenerator is enforced). The PrivacySlasher would also live on the
source chain (where EigenLayer staking and slashing infrastructure exists).
Option A) Source chain only: EpochRegistry deployed alongside OperatorRegistry on the source chain. PrivacySlasher on source chain can
read it directly. Dest chains don't need epoch state — they verify BLS certificates via BN254CertificateVerifier, not epoch checks.
Option B) Source + destination: Deploy on all chains via NewtonCrossChainDeploymentLib. Dest chain PrivacySlashers could reference local
epoch state. But we don't have dest chain slashers planned — slashing goes through the source chain.
My recommendation: Option A — source chain only. No consumer on destination chains needs epoch data. The PrivacySlasher (NEWT-640) and
DleqVerifier (NEWT-641) will live on the source chain. If a future dest-chain slasher needs epoch data, we can add cross-chain relay at
that point. Avoids unnecessary deployment complexity and transporter sync overhead.
A or B?
❯ A
⏺ Question 10 of N: operatorSetHash computation — who computes it and what's the canonical encoding?
The operatorSetHash is keccak256 of the participating operators. This needs to be deterministic so the PrivacySlasher can recompute it
from an operator list and verify it matches what's stored.
Two sub-questions:
Who computes it?
- Off-chain (gateway): gateway computes the hash and passes it in EpochInfo. Contract stores it blindly. Simpler contract, but the
contract trusts the gateway's hash. - On-chain (contract): gateway passes the raw operator address array, contract sorts and hashes. More gas, but the hash is verifiable
on-chain.
My recommendation: Off-chain. The gateway already knows the operator set (it built operator_sockets for the PSS ceremony). The
registerEpoch caller is already trusted (isTaskGenerator). Having the contract re-sort and hash an array adds gas for no security benefit
— the task generator is trusted to submit correct data.
Canonical encoding?
The hash must be reproducible. Two common patterns:
- keccak256(abi.encodePacked(sortedAddresses)) — compact, 20 bytes per address
- keccak256(abi.encode(sortedAddresses)) — ABI-encoded with padding, 32 bytes per address
My recommendation: abi.encodePacked with addresses sorted ascending. It's the more gas-efficient encoding and matches how operator sets
are typically hashed in EigenLayer middleware. Document the sort requirement in the interface NatSpec.
// Off-chain: gateway computes before calling registerEpoch
// bytes32 hash = keccak256(abi.encodePacked(sortedOperatorAddresses));
Agree with off-chain computation + abi.encodePacked of sorted addresses?
❯ Agreed
⏺ Question 11 of N: Should registerEpoch validate that mpk changed (or didn't change)?
PSS refresh preserves the MPK — that's the whole point. Resharing also preserves it. So mpk should be identical across all epochs unless
the initial DKG is re-run from scratch (which would be a new master secret, breaking all existing encrypted data).
Three options:
- A) Enforce MPK immutability: require(info.mpk == epochs[currentEpochId].mpk) for all epochs after 0. Catches bugs where a gateway
accidentally registers with a different MPK. - B) Allow MPK change: store whatever the gateway sends. Flexibility for re-keying scenarios (e.g., full operator set replacement where
resharing isn't possible). - C) Enforce MPK immutability with admin override: default enforce, but setMpk(bytes32) by admin for re-keying scenarios.
My recommendation: Option A with a carve-out for epoch 0. The MPK is the anchor — if it changes, all previously encrypted data becomes
undecryptable. This is a critical invariant. If we ever need to re-key (catastrophic compromise), that's a fresh DKG which should go
through a deliberate admin process — not something that happens silently via registerEpoch. We can add an admin resetToNewMpk() later if
needed.
if (currentEpochId > 0 || epochs[0].startBlock != 0) {
// Not the first epoch — MPK must be preserved
require(info.mpk == epochs[currentEpochId].mpk, MpkMismatch());
}
A, B, or C?
❯ A.
Question 12 of N: Configurable parameters — what's owner-settable after deployment?
We have one configurable parameter so far: gracePeriodEpochs. Let me think through if anything else needs to be tunable post-deployment.
┌────────────────────────────┬────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────┐
│ Parameter │ Set at deploy │ Owner-settable │ Rationale │
├────────────────────────────┼────────────────────────┼───────────────────────────────────┼──────────────────────────────────────────┤
│ operatorRegistry │ Constructor │ No │ Stable address, upgrade via proxy │
│ │ (immutable) │ │ redeploy │
├────────────────────────────┼────────────────────────┼───────────────────────────────────┼──────────────────────────────────────────┤
│ gracePeriodEpochs │ initialize() │ Yes │ May need tuning based on operator │
│ │ │ │ behavior │
├────────────────────────────┼────────────────────────┼───────────────────────────────────┼──────────────────────────────────────────┤
│ emergencyRotationRequested │ N/A │ Yes (via │ Already decided │
│ │ │ triggerEmergencyRotation) │ │
└────────────────────────────┴────────────────────────┴───────────────────────────────────┴──────────────────────────────────────────┘
I don't see other parameters that need runtime tuning. The threshold, committeeSize, and operatorSetHash are per-epoch (stored in
EpochInfo), not global config.
My recommendation: Single setter:
uint8 public gracePeriodEpochs;
function setGracePeriodEpochs(uint8 _gracePeriodEpochs) external onlyOwner {
gracePeriodEpochs = _gracePeriodEpochs;
emit GracePeriodUpdated(_gracePeriodEpochs);
}
Default value set in initialize() (e.g., 2). That's it — no other knobs needed.
Agree, or are there other parameters that should be tunable?
❯ Agree, or are there any scenarios that we need something tunable?
⏺ I've thought through potential scenarios:
- Epoch duration: lives off-chain in EpochConfig.duration_seconds — the contract doesn't enforce timing between epochs, it just records
what happened - MPK override for re-keying: decided to enforce immutability (Q11) — if we need re-keying later, add a dedicated admin function
- Max committee size: could cap committeeSize to prevent gas issues in future slashing loops, but the permissioned operator set is
already small (~20 max). Not worth the complexity now.
No additional tunables needed. Just gracePeriodEpochs.
Question 13 of N: Gas budget and storage layout
The spec targets registerEpoch < 80k gas. Let me estimate:
EpochInfo storage (per epoch):
epochId (uint64) — packed
threshold (uint8) — packed with epochId
committeeSize (uint8) — packed with epochId
mpk (bytes32) — 1 slot
operatorSetHash (bytes32)— 1 slot
startBlock (uint256) — 1 slot
That's 3 cold SSTOREs for a new epoch (~22,100 each = ~66k) plus currentEpochId update (warm SSTORE ~5k) plus the isTaskGenerator
external call (~2.6k) plus event emission (~1.5k). Total: ~75k gas. Tight but within 80k.
We can pack epochId (uint64) + threshold (uint8) + committeeSize (uint8) + startBlock (uint64, sufficient until year 2554) into a single
slot:
struct EpochInfo {
bytes32 mpk; // slot 0
bytes32 operatorSetHash; // slot 1
uint64 epochId; // slot 2 (packed)
uint64 startBlock; // slot 2 (packed)
uint8 threshold; // slot 2 (packed)
uint8 committeeSize; // slot 2 (packed)
}
That's 2 cold SSTOREs (~44k) + 1 cold SSTORE for the packed slot (~22k) = ~66k, leaving comfortable headroom.
My recommendation: Pack into 3 slots as shown above, using uint64 for startBlock (block numbers won't exceed 2^64). Ordering fields by
size descending for Solidity's left-packing rules. This keeps us well under the 80k gas target.
Agree with this storage layout?
⏺ I believe we've covered all the major design branches. Let me summarize the full design we've agreed on to make sure nothing was missed.
EpochRegistry Design Summary (13 decisions)
┌─────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ # │ Decision │ Choice │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 1 │ Deployment │ TransparentUpgradeableProxy (preserves epoch history across upgrades) │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 2 │ Access control │ isTaskGenerator(msg.sender) direct check for registerEpoch │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 3 │ Constructor deps │ operatorRegistry as immutable constructor param │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 4 │ EpochInfo fields │ mpk, operatorSetHash, epochId, startBlock, threshold, committeeSize — dropped endBlock and │
│ │ │ dkgCeremonyBlock │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 5 │ Grace period model │ Epoch-count based (currentEpochId - epochId <= gracePeriodEpochs) │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 6 │ Destruction │ Dropped — PSS refresh inherently replaces shares, attestations are unverifiable │
│ │ attestations │ │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 7 │ Emergency rotation │ On-chain signal flag + event, gateway watches and triggers off-chain ceremony │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 8 │ Epoch monotonicity │ Strict epochId == currentEpochId + 1, first epoch is 0 │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 9 │ Cross-chain │ Source chain only — no dest chain consumers │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 10 │ operatorSetHash │ Off-chain computed, keccak256(abi.encodePacked(sortedAddresses)) │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 11 │ MPK immutability │ Enforced — require(info.mpk == epochs[currentEpochId].mpk) after epoch 0 │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 12 │ Configurable params │ Only gracePeriodEpochs (owner-settable, default 2) │
├─────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 13 │ Storage layout │ 3 slots packed — uint64 startBlock, fields ordered for packing, <80k gas │
└─────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────┘
Contract skeleton:
contract EpochRegistry is Initializable, OwnableUpgradeable {
IOperatorRegistry public immutable operatorRegistry;
mapping(uint64 => EpochInfo) public epochs;
uint64 public currentEpochId;
uint8 public gracePeriodEpochs;
bool public emergencyRotationRequested;
// registerEpoch, isEpochActive, getEpoch, getCurrentEpoch
// triggerEmergencyRotation, setGracePeriodEpochs
}
Events: EpochRegistered, EmergencyRotationTriggered, GracePeriodUpdated
Not included (intentionally): destruction attestations, endBlock tracking, on-chain hash computation, dest chain deployment, EIP-712
signature auth, re-keying admin function
Anything missing or anything you'd change before we proceed to implementation?
Remaining Backlog (Phase 2D Solidity, unchanged):
┌──────────┬────────────────────────────────────┬─────────┐
│ Ticket │ Title │ Status │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-629 │ Privacy slashing conditions (spec) │ Backlog │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-640 │ PrivacySlasher contract │ Backlog │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-641 │ DleqVerifier library │ Backlog │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-630 │ On-chain aggregation commitments │ Backlog │
└──────────┴────────────────────────────────────┴─────────┘
Far future (Phase 3/4): MPC/ZK, mainnet deployment, ecosystem — all Backlog.
No manual status updates needed — the GitHub integration auto-marked NEWT-639 as Done when PR #544 merged. The remaining backlog tickets
are correctly in Backlog status, unblocked and ready for when we pick them up.
✻ Cooked for 1m 6s
❯ analyze the importance and implication of phase 2D. What is our current state in terms of newton privacy layer, and what does phase 2D
bring and why important?
⏺ ★ Insight ─────────────────────────────────────
This is a critical architectural question. Phase 2D is the economic enforcement layer — without it, the privacy system relies entirely on
operator honesty. Let me analyze what we have, what's missing, and why it matters.
─────────────────────────────────────────────────
Current State: Privacy Layer without Economic Enforcement
What we have (Phases 1→2C, all merged):
- HPKE encryption (X25519/ChaCha20Poly1305) for all three privacy paths
- Threshold DKG (FROST on Ristretto255) — operators hold key shares, MSK never assembled
- PSS epoch rotation — shares refresh every 24h, MPK constant, forward secrecy
- Gateway-never-decrypts — operators always decrypt locally
- Multi-domain resolution — identity, confidential, ephemeral all supported simultaneously
- EpochRegistry on-chain — epoch commitments, MPK immutability, grace periods
- Challenger support for privacy task replay
What's missing (Phase 2D):
- No economic penalty for operators who misbehave during threshold decryption
- No on-chain verification of partial decryption correctness
- No slashing for operators who refuse to participate
What Phase 2D Brings
NEWT-629: Slashing Conditions (spec)
Defines three provable violation types:
┌─────────────────────┬──────────────────────────────────────────────┬──────────────────────────────────────────────────┬───────────┐
│ Violation │ Detection │ Proof Type │ Severity │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ Wrong-keyshare │ Operator submits an invalid partial DH │ DLEQ proof failure (deterministic, │ 10% slash │
│ │ output │ cryptographic) │ │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ No-keyshare │ Operator absent during threshold decryption │ Quorum attestation (t-of-n operators attest to │ 1-2% │
│ │ window │ absence) │ slash │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ Premature │ Operator participates in unauthorized task │ On-chain authorization check against │ 10% slash │
│ decryption │ │ EpochRegistry │ │
└─────────────────────┴──────────────────────────────────────────────┴──────────────────────────────────────────────────┴───────────┘
NEWT-641: DleqVerifier Library
Solidity library using EIP-196 precompiles (bn128 alt_bn128) to verify DLEQ proofs on-chain:
Prove: log_G(PK_i) == log_{enc}(D_i)
This is the cryptographic primitive that makes "wrong-keyshare" provable on-chain. Without it, you can only detect wrong shares off-chain
(which is what the Rust DLEQ code already does), but you can't slash for it.
NEWT-640: PrivacySlasher Contract
Calls EigenLayer's InstantSlasher.fulfillSlashingRequest() when a violation is proven. References EpochRegistry.isEpochActive() and
getEpoch() to validate that the slashing claim is against a valid epoch.
Why Phase 2D Matters
Without Phase 2D, the privacy system is trust-based, not trust-minimized.
Right now, operators can:
- Submit garbage partial decryptions — the gateway detects this via DLEQ verification and excludes the operator, but the operator faces
zero economic consequences. They can keep doing it. - Refuse to participate — a malicious operator can selectively drop privacy tasks (censorship) with no penalty. As long as t-of-n others
participate, the task succeeds, but the attack is free. - Participate in unauthorized decryptions — if an operator's key share is compromised, there's no on-chain mechanism to prove misuse.
The practical impact:
- For users: Privacy data is protected by operator honesty, not economics. An operator could collude to reconstruct the master secret
(needs t operators) with no slashing risk. - For the protocol: Can't credibly claim "restaked security" for privacy operations. EigenLayer's value proposition is economic security
— without slashing, privacy is outside that model. - For auditors: A security audit would flag "no slashing for privacy violations" as a critical finding.
How Critical is Phase 2D?
For testnet/staging: Not blocking. The current system works correctly — DLEQ proofs verify off-chain, bad operators are excluded from
aggregation, and the permissioned operator set provides practical security.
For mainnet with real money: Phase 2D is a prerequisite. You can't launch privacy features with restaked ETH security claims unless
misbehavior has economic consequences. The DLEQ proof is the key differentiator from other threshold encryption systems — it makes
wrong-keyshare provable on-chain, not just detectable.
For the DAO transition: The PrivacySlasher is permissionless (anyone can submit a slashing proof). This aligns with decentralization —
you don't need a trusted entity to police operators.
Recommendation
Phase 2D is necessary for production but not urgent for the current testnet milestone. The natural order:
- Deploy EpochRegistry to stagef/testnet (just merged)
- Run PSS epoch rotation in production for a few cycles to validate stability
- Implement Phase 2D (NEWT-629 → NEWT-641 → NEWT-640) before mainnet privacy launch
- Security audit should cover both the Rust crypto (PSS, DLEQ) and the Solidity slashing contracts together
The DLEQ verification is the hard part — it requires mapping the Ristretto255 DLEQ proof to bn128 pairing-friendly operations on-chain,
which involves non-trivial curve arithmetic. That's why NEWT-641 exists as a separate library.
analyze the importance and implication of phase 2D. What is our current state in terms of newton privacy layer, and what does phase 2D
bring and why important?
⏺ ★ Insight ─────────────────────────────────────
This is a critical architectural question. Phase 2D is the economic enforcement layer — without it, the privacy system relies entirely on
operator honesty. Let me analyze what we have, what's missing, and why it matters.
─────────────────────────────────────────────────
Current State: Privacy Layer without Economic Enforcement
What we have (Phases 1→2C, all merged):
- HPKE encryption (X25519/ChaCha20Poly1305) for all three privacy paths
- Threshold DKG (FROST on Ristretto255) — operators hold key shares, MSK never assembled
- PSS epoch rotation — shares refresh every 24h, MPK constant, forward secrecy
- Gateway-never-decrypts — operators always decrypt locally
- Multi-domain resolution — identity, confidential, ephemeral all supported simultaneously
- EpochRegistry on-chain — epoch commitments, MPK immutability, grace periods
- Challenger support for privacy task replay
What's missing (Phase 2D):
- No economic penalty for operators who misbehave during threshold decryption
- No on-chain verification of partial decryption correctness
- No slashing for operators who refuse to participate
What Phase 2D Brings
NEWT-629: Slashing Conditions (spec)
Defines three provable violation types:
┌─────────────────────┬──────────────────────────────────────────────┬──────────────────────────────────────────────────┬───────────┐
│ Violation │ Detection │ Proof Type │ Severity │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ Wrong-keyshare │ Operator submits an invalid partial DH │ DLEQ proof failure (deterministic, │ 10% slash │
│ │ output │ cryptographic) │ │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ No-keyshare │ Operator absent during threshold decryption │ Quorum attestation (t-of-n operators attest to │ 1-2% │
│ │ window │ absence) │ slash │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ Premature │ Operator participates in unauthorized task │ On-chain authorization check against │ 10% slash │
│ decryption │ │ EpochRegistry │ │
└─────────────────────┴──────────────────────────────────────────────┴──────────────────────────────────────────────────┴───────────┘
NEWT-641: DleqVerifier Library
Solidity library using EIP-196 precompiles (bn128 alt_bn128) to verify DLEQ proofs on-chain:
Prove: log_G(PK_i) == log_{enc}(D_i)
This is the cryptographic primitive that makes "wrong-keyshare" provable on-chain. Without it, you can only detect wrong shares off-chain
(which is what the Rust DLEQ code already does), but you can't slash for it.
NEWT-640: PrivacySlasher Contract
Calls EigenLayer's InstantSlasher.fulfillSlashingRequest() when a violation is proven. References EpochRegistry.isEpochActive() and
getEpoch() to validate that the slashing claim is against a valid epoch.
Why Phase 2D Matters
Without Phase 2D, the privacy system is trust-based, not trust-minimized.
Right now, operators can:
- Submit garbage partial decryptions — the gateway detects this via DLEQ verification and excludes the operator, but the operator faces
zero economic consequences. They can keep doing it. - Refuse to participate — a malicious operator can selectively drop privacy tasks (censorship) with no penalty. As long as t-of-n others
participate, the task succeeds, but the attack is free. - Participate in unauthorized decryptions — if an operator's key share is compromised, there's no on-chain mechanism to prove misuse.
The practical impact:
- For users: Privacy data is protected by operator honesty, not economics. An operator could collude to reconstruct the master secret
(needs t operators) with no slashing risk. - For the protocol: Can't credibly claim "restaked security" for privacy operations. EigenLayer's value proposition is economic security
— without slashing, privacy is outside that model. - For auditors: A security audit would flag "no slashing for privacy violations" as a critical finding.
How Critical is Phase 2D?
For testnet/staging: Not blocking. The current system works correctly — DLEQ proofs verify off-chain, bad operators are excluded from
aggregation, and the permissioned operator set provides practical security.
For mainnet with real money: Phase 2D is a prerequisite. You can't launch privacy features with restaked ETH security claims unless
misbehavior has economic consequences. The DLEQ proof is the key differentiator from other threshold encryption systems — it makes
wrong-keyshare provable on-chain, not just detectable.
For the DAO transition: The PrivacySlasher is permissionless (anyone can submit a slashing proof). This aligns with decentralization —
you don't need a trusted entity to police operators.
Recommendation
Phase 2D is necessary for production but not urgent for the current testnet milestone. The natural order:
- Deploy EpochRegistry to stagef/testnet (just merged)
- Run PSS epoch rotation in production for a few cycles to validate stability
- Implement Phase 2D (NEWT-629 → NEWT-641 → NEWT-640) before mainnet privacy launch
- Security audit should cover both the Rust crypto (PSS, DLEQ) and the Solidity slashing contracts together
The DLEQ verification is the hard part — it requires mapping the Ristretto255 DLEQ proof to bn128 pairing-friendly operations on-chain,
which involves non-trivial curve arithmetic. That's why NEWT-641 exists as a separate library.
✻ Sautéed for 52s
❯ How does privacy slashing work in had with the current slashing logic we have? For example, if policy evaluation was correct, but
privacy decryption was incorrect, who get's slashed? is it just privacy decyrption operated operators?
⏺ ★ Insight ─────────────────────────────────────
This is a critical trust model question. Newton currently has two independent slashing mechanisms, and Phase 2D adds a third.
Understanding how they compose is essential for the spec.
─────────────────────────────────────────────────
Current Slashing Architecture
Let me check how the existing slashing works to give a precise answer:
Searched for 2 patterns (ctrl+o to expand)
⏺ Now I have the full picture. Here's how the two slashing systems interact:
Current Slashing: Policy Evaluation Correctness
The existing system slashes operators for incorrect policy evaluation results:
- raiseAndResolveChallenge — A challenger re-evaluates the policy independently (including generating a ZK proof via SP1). If the
operator's evaluationResult was wrong, ChallengeLib.slashSigningOperators() slashes all operators who BLS-signed the incorrect response
at 10% (WADS_TO_SLASH = 100000000000000000). - challengeDirectlyVerifiedAttestation — Same mechanism for the direct attestation path.
- slashForCrossChainChallenge — Cross-chain relay of a challenge from a destination chain.
Who gets slashed: Every operator whose BLS signature is in the aggregated response. The BLS aggregation means all signers are
collectively liable — you can't distinguish which individual operator produced a wrong sub-result.
Phase 2D: Privacy Decryption Correctness
Privacy slashing is fundamentally different from policy slashing because it targets a different failure mode:
┌─────────────────┬───────────────────────────────────────┬─────────────────────────────────────────────────┐
│ Aspect │ Policy Slashing (current) │ Privacy Slashing (Phase 2D) │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ What failed │ Policy evaluation result was wrong │ Threshold decryption share was wrong or missing │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ Who is liable │ All BLS signers (collective) │ Individual operator (targeted) │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ Proof type │ ZK proof of correct evaluation (SP1) │ DLEQ proof of correct partial DH │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ Detection │ Challenger re-evaluates independently │ Gateway verifies each operator's DLEQ proof │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ When it happens │ After respondToTask on-chain │ During Prepare phase (off-chain, before BLS) │
└─────────────────┴───────────────────────────────────────┴─────────────────────────────────────────────────┘
The Key Interaction: Your Scenario
"Policy evaluation correct, but privacy decryption incorrect"
This is the exact scenario Phase 2D addresses. Here's the timeline:
- Prepare phase: Gateway asks operators for partial DH outputs. Operator A submits a wrong partial decryption with an invalid DLEQ
proof. - Gateway detects it: The Rust DLEQ verification (crates/core/src/dkg/dleq.rs) rejects Operator A's contribution. Gateway excludes
Operator A and proceeds with the remaining t-1 operators (if enough remain). - Commit phase: Policy evaluation happens with the correct decrypted data (from honest operators). BLS aggregation succeeds. The policy
result is correct. - On-chain: respondToTask lands. No policy challenge is raised (the result was correct).
Without Phase 2D: Operator A submitted a bad share, was excluded, and faces zero consequences. They can keep doing this — degrading
liveness (fewer operators available for threshold) without any economic risk.
With Phase 2D: The gateway submits Operator A's invalid DLEQ proof to the PrivacySlasher contract. The DleqVerifier library verifies the
proof is indeed invalid on-chain. Operator A gets slashed 10%. Only Operator A — not the other honest operators.
Separation of Concerns
The two slashing systems are independent and composable:
Policy slashing: BLS signers of wrong result → 10% slash (collective)
Privacy slashing: Individual bad decryptor → 10% slash (targeted)
An operator could be slashed by both systems in theory:
- They submit a wrong partial decryption AND sign a wrong policy result
- Privacy slash hits them individually for the bad share
- Policy slash hits all signers (including them) for the bad result
But in practice, the bad decryption is caught in the Prepare phase (before BLS signing), so the operator is excluded before they can sign
a wrong result. The two failure modes are temporally separated.
What About "No-Keyshare" (Liveness)?
The 1-2% slash for absence is lighter because:
- The operator might be legitimately down (network issues)
- It only matters if their absence drops below threshold (n-absent < t)
- Detection is via quorum attestation (t operators attest "Operator A didn't respond"), not cryptographic proof
This is similar to how EigenLayer handles general liveness failures, just scoped to the privacy decryption window.
✻ Crunched for 1m 21s
❯ But in this model, Gateway is being trusted that it will honestly submit onchain to slash the bad operators. Isn't it? is there a way
for the current challenger to do so?
⏺ You've identified the critical trust assumption. Yes — in the current Phase 2D design, the gateway is the only entity that sees the raw
DLEQ proofs during the Prepare phase. If the gateway is compromised or colluding with a bad operator, it can simply not submit the
slashing proof.
The Trust Gap
┌───────────────────────────────┬─────────────────────────┬──────────────────┬─────────────────────────────────────────────────────┐
│ Slashing Type │ Who Detects │ Who Submits │ Trust Assumption │
│ │ │ Proof │ │
├───────────────────────────────┼─────────────────────────┼──────────────────┼─────────────────────────────────────────────────────┤
│ Policy (current) │ Challenger │ Challenger │ Trustless — anyone can re-evaluate and challenge │
│ │ (independent) │ │ │
├───────────────────────────────┼─────────────────────────┼──────────────────┼─────────────────────────────────────────────────────┤
│ Privacy (Phase 2D as │ Gateway │ Gateway │ Trusted — gateway must be honest about DLEQ │
│ designed) │ │ │ failures │
└───────────────────────────────┴─────────────────────────┴──────────────────┴─────────────────────────────────────────────────────┘
The current challenger cannot detect privacy decryption failures because:
- The challenger doesn't participate in the Prepare phase — it only sees the final respondToTask on-chain
- The DLEQ proofs are exchanged off-chain between operators and the gateway
- By the time the task response is on-chain, the bad operator was already excluded and the result is correct
Could the Challenger Do It?
For the challenger to independently verify privacy decryption correctness, it would need:
Option A: On-chain DLEQ commitments (NEWT-630)
This is actually what NEWT-630 (on-chain aggregation commitments) is designed for. If the gateway publishes a commitment of all partial
decryptions + DLEQ proofs on-chain (or to EigenDA), then:
- Gateway posts commitmentRoot = keccak256(operator_id, D_i, pi_i) per task
- Challenger can request the individual proofs from EigenDA/gateway
- Challenger verifies each DLEQ proof independently
- If a proof is invalid, challenger submits slashing proof to PrivacySlasher
This makes privacy slashing trustless — same model as policy slashing. The gateway can't hide a bad operator because the commitment is
on-chain.
Option B: Operators report each other
In the Prepare phase, each operator receives partial decryptions from peers (in a broadcast model). If Operator B sees Operator A's
invalid DLEQ proof, Operator B could submit the slashing proof directly. But currently the gateway mediates all communication — operators
don't see each other's proofs.
Option C: Broadcast partial decryptions + proofs publicly
Post all DLEQ proofs to a public data availability layer. Anyone can verify. But this leaks partial decryption data which has privacy
implications.
The Right Architecture
The combination of NEWT-630 (commitments) + NEWT-640 (PrivacySlasher) makes it trustless:
Prepare phase:
Gateway collects partials + DLEQ proofs from operators
Gateway verifies DLEQ proofs (excludes bad operators)
Gateway posts commitment root on-chain (NEWT-630)
Challenge window:
Challenger fetches individual proofs via EigenDA or gateway
Challenger verifies each DLEQ proof independently
If invalid: submits to PrivacySlasher (NEWT-640)
This is why NEWT-630 exists alongside NEWT-640/641 — without the commitments, the slashing is gateway-trusted. With commitments, any
external party can verify and slash, matching the existing challenger model for policy evaluation.
Practical Recommendation
For the implementation order, I'd suggest:
- NEWT-641 (DleqVerifier library) — pure cryptographic library, no trust assumptions
- NEWT-630 (on-chain commitments) — makes DLEQ proofs publicly verifiable
- NEWT-640 (PrivacySlasher) — references both DleqVerifier and commitments
- NEWT-629 (spec update) — refine the spec to require commitment-based detection, not gateway-based
This way the slashing system is trustless from day one, matching Newton's existing challenger model. Want me to update the Linear tickets
to reflect this dependency ordering and the commitment requirement?
⏺ All 4 tickets updated. Here's the final dependency graph:
NEWT-628 (PSS epoch rotation) ──── DONE
└── NEWT-639 (EpochRegistry) ──── DONE
NEWT-641 (DleqVerifier library) ──── Backlog
└── blocks NEWT-640
NEWT-630 (On-chain commitments) ──── Backlog
├── blocks NEWT-629
└── blocks NEWT-640
NEWT-629 (Slashing spec) ──── Backlog, blocked by NEWT-628 (done) + NEWT-630
├── NEWT-640 (PrivacySlasher) ──── Backlog, blocked by NEWT-641 + NEWT-630
└── NEWT-641 (DleqVerifier) ──── Backlog
Implementation order:
- NEWT-641 — DleqVerifier library (no blockers, pure crypto)
- NEWT-630 — On-chain commitments (no blockers, extends TaskResponseMetadata)
- NEWT-640 — PrivacySlasher (blocked by both above)
- NEWT-629 — Spec finalization (refine based on implementation)
Key updates made:
- NEWT-629: Rewrote to emphasize trustless detection model, removed Tier 3 plaintext leakage (deferred to TEE), added policy-vs-privacy
slashing comparison table - NEWT-640: Updated with permissionless design, explicit DleqVerifier + commitments dependency, slashing flow for both violation types
- NEWT-641: Added blocks: NEWT-640 relation
- NEWT-630: Added blocks: NEWT-640 relation
❯ one more question. is PrivacySlasher purely for Prepare phase?
⏺ No — it covers both phases, but different violation types map to different phases:
┌──────────────────────────┬───────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│ Violation │ Phase │ Why │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Wrong-keyshare (DLEQ │ Prepare │ Operators submit partial DH outputs during Prepare. The DLEQ proof accompanies each │
│ failure) │ phase │ partial. │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ No-keyshare (liveness) │ Prepare │ Operator fails to respond during the Prepare phase timeout. │
│ │ phase │ │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Wrong BLS signature on │ Commit │ Operator BLS-signs a policy result that was computed from incorrect decrypted data. But │
│ privacy result │ phase │ this is already covered by the existing policy challenger (ZK proof) — not PrivacySlasher's │
│ │ │ scope. │
└──────────────────────────┴───────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘
So practically, the PrivacySlasher targets Prepare phase misbehavior — that's where threshold decryption happens. The Commit phase
(policy evaluation + BLS signing) is already covered by the existing ChallengeVerifier.
There's one edge case worth noting: in the gateway-never-decrypts model, operators also decrypt locally during the Commit phase (they
receive partial DH outputs from Prepare and complete HPKE decryption). But that local decryption is deterministic given correct partials
— if the partials were valid (verified via DLEQ in Prepare), the Commit-phase decryption can't be wrong. So there's no separate
Commit-phase privacy violation to slash for.
The flow:
Prepare phase: partials + DLEQ proofs → PrivacySlasher can slash
Commit phase: policy eval + BLS sign → ChallengeVerifier can slash (existing)
Two independent slashing systems, one per phase. No overlap, no gaps.
Let's proceed to next steps
⏺ The natural next step is NEWT-641 (DleqVerifier library) — it's the first in the dependency chain, has no blockers, and is pure
cryptographic library code.
But before diving into implementation, there's a significant design challenge worth discussing: the curve mismatch. Our DLEQ proofs are
generated on Ristretto255 (Rust side), but EVM precompiles (EIP-196) operate on alt_bn128 (BN254). These are fundamentally different
curves — you can't directly verify a Ristretto255 DLEQ proof using BN254 precompiles.
This needs a /grill-me before implementation. The ticket mentions two approaches:
- Proof translation — operator generates on Ristretto255, translates for BN254 verification
- Dual-curve commitment — operator commits public key share on both curves during DKG
There may be a third option worth exploring: verifying the DLEQ proof in a ZK circuit (SP1) instead of via EVM precompiles — we already
have the SP1 infrastructure for policy challenges.
Want to start with /grill-me on the DleqVerifier curve strategy, or would you prefer to tackle something else first?
✻ Crunched for 31s
❯ yes /grill-me on the DleqVerifier curve strategy
⏺ Skill(grill-me)
⎿ Successfully loaded skill
⏺ Let me first understand the exact DLEQ proof structure we're working with on the Rust side.
Read 1 file (ctrl+o to expand)
⏺ Good — now I have the exact DLEQ structure. The proof operates on Ed25519 (Curve25519 in Edwards form), not Ristretto255 as I initially
said. The proof is:
- c = SHA-256("newton-dleq-v1" || G || A || H || B || R1 || R2) mod order
- z = k - c * secret
- Verification: zG + cA == R1 and zH + cB == R2
This is standard Schnorr-style DLEQ on Ed25519 (group order ~2^252). EVM precompiles operate on BN254 (alt_bn128, group order ~2^254).
These are different curves with different group orders, base points, and coordinate systems.
Question 1 of N: Which verification approach should we use?
Three options with fundamentally different trade-offs:
A) Native Solidity verification on Ed25519
Implement Ed25519 point arithmetic in pure Solidity — scalar multiplication via double-and-add, point addition, SHA-256 hash. No
precompiles needed (SHA-256 is available as a precompile at address 0x02). But Ed25519 scalar mul in Solidity is extremely expensive —
~1M+ gas per verification (no native precompile for Curve25519).
B) SP1 ZK circuit verification
The DLEQ verification runs inside an SP1 zkVM circuit (we already have SP1 infrastructure for sp1-rego policy challenges). The circuit
verifies the DLEQ proof off-chain and produces a succinct proof. On-chain, only the SP1 proof is verified (~300k gas via the SP1 verifier
contract). The operator's DLEQ proof correctness is reduced to "verify this ZK proof."
C) Dual-curve scheme
During DKG/PSS, operators commit their public key share on both Ed25519 (for HPKE threshold decryption) and BN254 (for on-chain DLEQ
verification). Operators generate a second DLEQ proof on BN254 alongside the Ed25519 one. The BN254 proof can be verified on-chain using
EIP-196/197 precompiles (~40k gas). But this doubles the DKG key material and requires operators to maintain two key shares.
My recommendation: Option B (SP1 ZK circuit). Reasons:
- We already have SP1 infrastructure (circuits/sp1-rego/) and the SP1 verifier deployed on-chain
- No changes to the DKG/PSS ceremony or key material
- The DLEQ verification is simple enough for an SP1 circuit (~100 lines)
- Gas cost is moderate (~300k) but acceptable for a slashing path (not hot path)
- The Ed25519 arithmetic runs natively inside SP1's RISC-V environment (no field translation)
- Aligns with the existing challenger pattern: challenger generates ZK proof, submits on-chain
A, B, or C?
✻ Churned for 51s
❯ How complex is C? "But this doubles the DKG key material and requires operators to maintain two key shares." --> what does this entail
in terms of complexity, error-prone-ness, reliability, efficiency?
⏺ Good question. Let me break down exactly what dual-curve entails:
What "Two Key Shares" Means Concretely
Currently each operator has:
- One secret scalar s_i (a 32-byte number)
- One public share pk_i = s_i * G_ed25519 (Ed25519 point)
With dual-curve, each operator would have:
- Same secret scalar s_i (it's just a number — curve-agnostic)
- Ed25519 public share: pk_i_ed = s_i * G_ed25519
- BN254 public share: pk_i_bn = s_i * G_bn254
The secret scalar is the same — you don't need two separate secrets. You just compute the public share on both curves. The scalar s_i is
~252 bits; both Ed25519 (order ~2^252) and BN254 (order ~2^254) can accommodate it.
Complexity Assessment
DKG ceremony changes:
- frost.rs round3: after computing s_i, also compute s_i * G_bn254 and store it
- keystore.rs: store both public shares in the encrypted keystore
- types.rs: add bn254_public_share: [u8; 64] to KeyShare (BN254 points are 64 bytes uncompressed)
- FROST package broadcast: include the BN254 public share in round3 output so all parties know it
~50 lines of code change in the DKG crate. Not trivial but not massive.
PSS refresh changes:
- refresh.rs: Feldman commitments are currently Ed25519 only. Would need BN254 commitments too (or derive from the same polynomial, since
coefficients are scalars) - Actually — the polynomial coefficients are scalars, and Feldman verification is share * G == sum(C_j * x^j). If we compute Feldman
commitments on both curves, verification works on both independently - ~30 lines additional in refresh.rs
Operator runtime changes:
- During Prepare phase: operator generates DLEQ proof on BN254 instead of (or in addition to) Ed25519
- Requires a BN254 scalar multiplication library in Rust (e.g., ark-bn254 or alloy's BN254 support)
- New dependency in the operator crate
On-chain changes:
- EpochRegistry or a companion contract stores BN254 public shares per operator per epoch
- Additional ~21k gas per operator per epoch for storage
- DleqVerifier uses EIP-196 ecMul + ecAdd: ~12k gas for verification (very cheap)
Error-Proneness
The risky part: the scalar s_i must be valid in both group orders. Ed25519 order is 2^252 + 27742... and BN254 order is 21888...
(~2^254). Since the BN254 order is larger, any valid Ed25519 scalar is also valid for BN254. This works safely — no modular reduction
needed.
But: if we ever change the DKG to generate scalars mod BN254's order (larger), they might not be valid Ed25519 scalars. This is a latent
footgun. The rule would be: always generate scalars mod Ed25519 order (the smaller one).
FROST interaction: FROST DKG on Ristretto255 generates scalars mod the Ristretto255 group order (same as Ed25519). These are guaranteed
valid for BN254. No issue with the current FROST implementation.
Reliability
- One more thing that can go wrong during DKG: BN254 key derivation failure
- One more field to serialize/deserialize in keystore
- One more commitment to verify during PSS refresh
- But all of these are deterministic — if the scalar is valid (which it is by construction), the BN254 operations can't fail
Efficiency
┌───────────────────────┬──────────────────────────────┬────────────────────────────────────────────────────┐
│ Aspect │ Current (Ed25519 only) │ Dual-Curve │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ DKG ceremony │ 3 rounds │ 3 rounds (same, +1 point computation per operator) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ Keystore size │ ~200 bytes │ ~264 bytes (+64 bytes for BN254 point) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ PSS refresh │ 1 set of Feldman commitments │ 2 sets (but can share polynomial) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ Prepare phase │ 1 DLEQ proof (Ed25519) │ 1 DLEQ proof (BN254) — can drop Ed25519 proof │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ On-chain verification │ N/A │ ~12k gas (ecMul precompile) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ On-chain storage │ 0 │ ~21k gas per operator per epoch │
└───────────────────────┴──────────────────────────────┴────────────────────────────────────────────────────┘
Compared to SP1 (Option B)
┌───────────────────────────┬───────────────────────────┬───────────────────────────────────┐
│ Aspect │ SP1 ZK Circuit │ Dual-Curve │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ On-chain verification gas │ ~300k (SP1 verifier) │ ~12k (ecMul precompile) │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ Proof generation time │ ~10-30s (SP1 prover) │ ~0ms (BN254 point mul is instant) │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ New dependencies │ None (SP1 already exists) │ ark-bn254 or similar in operator │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ DKG changes │ None │ ~80 lines │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ Slashing latency │ Slow (proof generation) │ Instant (precompile verification) │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ Complexity locus │ Circuit code │ DKG + keystore + refresh │
└───────────────────────────┴───────────────────────────┴───────────────────────────────────┘
My Updated Assessment
Dual-curve is more complex upfront (~80 lines across DKG/PSS/keystore) but dramatically cheaper and faster at verification time (12k gas
vs 300k, instant vs 10-30s proof generation). The complexity is contained in well-tested crypto code (scalar multiplication is
deterministic), not in a new ZK circuit.
SP1 is simpler to implement (no DKG changes) but slower and more expensive when actually slashing.
Revised recommendation: If slashing speed matters (a bad operator should be slashable immediately, not after waiting for SP1 proof
generation), dual-curve is better. If implementation speed matters and slashing is rare, SP1 is better.
What's your priority — fast implementation or fast slashing?
✻ Cooked for 45s
❯ reliability and least error-prone (technically and operationally). Least execution risk.
⏺ With that criteria, Option B (SP1 ZK circuit) is the clear winner.
Here's why:
Dual-curve (C) execution risks:
- Modifying the DKG ceremony is high-stakes — FROST is the most complex crypto in the codebase. Any bug in round3 BN254 key derivation
could produce invalid key material across all operators, requiring a fresh DKG ceremony to recover - PSS refresh with dual Feldman commitments doubles the verification surface — a bug means operators silently accumulate invalid BN254
shares across epochs - The "scalar valid in both group orders" invariant is a latent footgun that every future contributor must know about
- New ark-bn254 dependency in the operator crate — different serialization format, different point representation, new attack surface
- Every operator must upgrade simultaneously to support BN254 — partial rollout breaks the ceremony
SP1 (B) execution risks:
- The DLEQ verification logic is ~30 lines of Rust (same code as dleq.rs::verify(), compiled to RISC-V)
- No changes to DKG, PSS, keystore, or operator runtime
- No new dependencies — curve25519-dalek already works in SP1's RISC-V target
- The SP1 verifier contract is already deployed and battle-tested on-chain
- Slashing is rare (dispute path, not hot path) — 10-30s proof generation is acceptable
- If the SP1 circuit has a bug, fix and redeploy the circuit — no operator coordination needed
The decisive factor: with dual-curve, a bug corrupts live key material across all operators. With SP1, a bug means a slashing proof fails
to verify — fixable without touching the privacy system.
Let me continue the grill.
Question 2 of N: SP1 circuit scope — verify a single DLEQ proof, or verify the full commitment?
Two circuit designs:
A) Single DLEQ verification: The circuit takes one operator's DLEQ proof + public inputs (G, pk_i, H, D_i) and outputs pass/fail. The
challenger generates one SP1 proof per operator they want to slash.
B) Commitment batch verification: The circuit takes the full commitment from NEWT-630 (all operator partials + DLEQ proofs for a task)
and verifies all of them, outputting a bitmap of which operators' proofs are invalid. One SP1 proof covers the entire task.
My recommendation: Option A (single DLEQ). Simpler circuit, faster proof generation (one operator's proof vs all), and the PrivacySlasher
only needs to slash one operator at a time. Batch can be added later if needed.
A or B?
✻ Brewed for 30s
❯ B) Commitment batch verification: The circuit takes the full commitment from NEWT-630 (all operator partials + DLEQ proofs for a task)
and verifies all of them, outputting a bitmap of which operators' proofs are invalid. One SP1 proof covers the entire task. -->
similar to how we slashing in ChallengeVerifier
⏺ Exactly — matches the existing pattern in ChallengeLib.slashSigningOperators() which slashes all bad signers in one batch. Consistent
architecture.
Question 3 of N: What are the public inputs and outputs of the SP1 circuit?
The circuit needs to:
- Take in all DLEQ proofs for a task
- Verify each against the operator's committed public share
- Output which operators failed
Public inputs (committed on-chain, verifiable by anyone):
- decryptionCommitment (bytes32) — from NEWT-630, the on-chain commitment hash
- epochId (uint64) — which epoch's key shares to verify against
- taskId (bytes32) — which task's partial decryptions
Private inputs (provided by the challenger, not on-chain):
- Per-operator: (operator_index, pk_share, enc_point, partial_decryption, dleq_proof)
- These are fetched from EigenDA or the gateway's data availability layer
Public outputs (written to the proof, verified on-chain):
- invalidOperatorBitmap (uint256) — bitmask of which operators submitted invalid DLEQ proofs
- commitmentHash (bytes32) — re-computed from the private inputs, must match the on-chain decryptionCommitment
The on-chain PrivacySlasher then:
- Verifies the SP1 proof
- Checks commitmentHash == stored decryptionCommitment (binding)
- Slashes each operator flagged in invalidOperatorBitmap
My recommendation: This structure directly. The commitmentHash binding prevents the challenger from fabricating inputs — they must use
the same data the gateway committed to.
Does this input/output structure look right, or do you want to adjust?
✻ Cogitated for 36s
❯ looks right
⏺ Question 4 of N: Where does the challenger get the raw DLEQ proofs (private inputs)?
The on-chain commitment (NEWT-630) stores only a hash. The challenger needs the actual per-operator data to feed into the SP1 circuit.
Three options:
▐▛███▜▌ Claude Code v2.1.108
▝▜█████▛▘ Opus 4.6 (1M context) with max effort · API Usage Billing
▘▘ ▝▝ ~/projects/newton-prover-avs/contracts
⚠ Large .claude/rules/lessons.md will impact performance (48.3k chars > 40.0k) · /memory to edit
⏺ That's a strong argument for keeping it on-chain. When admin transitions to a DAO, on-chain transactions are the only way the DAO can
interact — there's no "call the gateway RPC" option for a multisig or governance contract. The current design is forward-compatible with
that.
Want me to add that context as a reply on Albert's thread? Something like: "admin will be a DAO in the future — on-chain tx is the only
interface available to governance contracts. keeping it on-chain now avoids a migration later."
❯ update our comment
⏺ Bash(gh api graphql -f query='
mutation {…)
⎿ ok
⏺ Posted. The thread now has the full reasoning: Safe multisig access pattern, block time latency, auditability, and DAO
forward-compatibility.
✻ Churned for 1m 10s
❯ also another engineer asks "that also means an ethereum halt (not often anymore) could compromise the platform for the length of a halt
right?"
⏺ That's a valid concern but the impact is limited. Let me think through it:
An Ethereum halt means no new blocks → the chain watcher can't detect the EmergencyRotationTriggered event → the gateway doesn't trigger
an immediate PSS refresh. But:
- The emergency signal is a speed-up, not a gate. The EpochManager already rotates on a timer (default 24h). An Ethereum halt delays the
emergency rotation, not all rotation. Scheduled rotations are purely off-chain (operator RPC calls) and don't depend on Ethereum being
live. - The halt also blocks the attacker. If Ethereum is halted, the compromised key share can't be exploited on-chain either — no
transactions can land. The privacy data is only useful in the context of on-chain task submissions, which are also blocked. - The actual blast radius is: time between the admin detecting the compromise and the next scheduled refresh, which is at most
epoch_duration_seconds (24h default). The emergency signal would reduce that to ~seconds, but during a halt it falls back to the timer.
Should I post this reasoning as a reply? The short version: "Ethereum halt delays the emergency signal, but scheduled rotations are
off-chain and unaffected. The halt also blocks the attacker from exploiting on-chain. Blast radius is bounded by epoch_duration_seconds,
not the halt duration."
❯ what are pro vs cons of the two approaches?
⏺ On-chain emergency signal (current approach)
Pros:
- DAO-compatible — governance contracts can only interact via on-chain tx
- Auditable — when admin requested rotation vs when it completed is visible on-chain
- No network access needed — admin (Safe multisig) doesn't need gateway RPC endpoint
- Immutable record — can't be disputed or lost
Cons:
- Latency tied to block time (2-24s depending on chain)
- Publicly broadcasts that an emergency occurred (operational info leak)
- Ethereum halt delays the signal (falls back to scheduled rotation timer)
- Gas cost for the tx (minimal, ~27k)
Off-chain emergency signal (admin RPC endpoint on gateway)
Pros:
- Instant — no block time latency, sub-second
- Private — no public broadcast of the emergency
- Works during chain halt
- Zero gas cost
Cons:
- Not DAO-compatible — governance contracts can't call an HTTP endpoint
- Requires admin to have network access to the gateway (VPN, internal network)
- No immutable audit trail — log-based only, can be tampered
- Requires separate auth mechanism for the admin endpoint (API key, mTLS)
- Gateway being down/unreachable blocks the emergency signal entirely
The key differentiator is the DAO trajectory. If admin stays as an EOA/multisig with gateway access, off-chain is simpler. Once admin
becomes a DAO, on-chain is the only option. Since we're planning the DAO transition, on-chain is the forward-compatible choice.
Want me to post a summary of this on the PR thread?
✻ Cooked for 31s
❯ this PR has been merged. Let's update Linear project task status to reflect the current progress
⏺ Let me check the current status of NEWT-639 and update it:
Called linear-server (ctrl+o to expand)
⏺ NEWT-639 is already marked Done (auto-closed by PR #544 merge). The PR attachment is linked. Let me check the remaining tickets to see if
any need status updates now that NEWT-639 is complete:
Called linear-server (ctrl+o to expand)
⏺ Linear is already up to date. Here's the current state:
Done (auto-closed by PR merges):
- NEWT-639 (EpochRegistry) — Done, PR #544 attached
- NEWT-628 (PSS epoch rotation) — Done
- All Phase 1→2C tickets — Done
Remaining Backlog (Phase 2D Solidity, unchanged):
┌──────────┬────────────────────────────────────┬─────────┐
│ Ticket │ Title │ Status │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-629 │ Privacy slashing conditions (spec) │ Backlog │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-640 │ PrivacySlasher contract │ Backlog │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-641 │ DleqVerifier library │ Backlog │
├──────────┼────────────────────────────────────┼─────────┤
│ NEWT-630 │ On-chain aggregation commitments │ Backlog │
└──────────┴────────────────────────────────────┴─────────┘
Far future (Phase 3/4): MPC/ZK, mainnet deployment, ecosystem — all Backlog.
No manual status updates needed — the GitHub integration auto-marked NEWT-639 as Done when PR #544 merged. The remaining backlog tickets
are correctly in Backlog status, unblocked and ready for when we pick them up.
✻ Cooked for 1m 6s
❯ analyze the importance and implication of phase 2D. What is our current state in terms of newton privacy layer, and what does phase 2D
bring and why important?
⏺ ★ Insight ─────────────────────────────────────
This is a critical architectural question. Phase 2D is the economic enforcement layer — without it, the privacy system relies entirely on
operator honesty. Let me analyze what we have, what's missing, and why it matters.
─────────────────────────────────────────────────
Current State: Privacy Layer without Economic Enforcement
What we have (Phases 1→2C, all merged):
- HPKE encryption (X25519/ChaCha20Poly1305) for all three privacy paths
- Threshold DKG (FROST on Ristretto255) — operators hold key shares, MSK never assembled
- PSS epoch rotation — shares refresh every 24h, MPK constant, forward secrecy
- Gateway-never-decrypts — operators always decrypt locally
- Multi-domain resolution — identity, confidential, ephemeral all supported simultaneously
- EpochRegistry on-chain — epoch commitments, MPK immutability, grace periods
- Challenger support for privacy task replay
What's missing (Phase 2D):
- No economic penalty for operators who misbehave during threshold decryption
- No on-chain verification of partial decryption correctness
- No slashing for operators who refuse to participate
What Phase 2D Brings
NEWT-629: Slashing Conditions (spec)
Defines three provable violation types:
┌─────────────────────┬──────────────────────────────────────────────┬──────────────────────────────────────────────────┬───────────┐
│ Violation │ Detection │ Proof Type │ Severity │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ Wrong-keyshare │ Operator submits an invalid partial DH │ DLEQ proof failure (deterministic, │ 10% slash │
│ │ output │ cryptographic) │ │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ No-keyshare │ Operator absent during threshold decryption │ Quorum attestation (t-of-n operators attest to │ 1-2% │
│ │ window │ absence) │ slash │
├─────────────────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────────┼───────────┤
│ Premature │ Operator participates in unauthorized task │ On-chain authorization check against │ 10% slash │
│ decryption │ │ EpochRegistry │ │
└─────────────────────┴──────────────────────────────────────────────┴──────────────────────────────────────────────────┴───────────┘
NEWT-641: DleqVerifier Library
Solidity library using EIP-196 precompiles (bn128 alt_bn128) to verify DLEQ proofs on-chain:
Prove: log_G(PK_i) == log_{enc}(D_i)
This is the cryptographic primitive that makes "wrong-keyshare" provable on-chain. Without it, you can only detect wrong shares off-chain
(which is what the Rust DLEQ code already does), but you can't slash for it.
NEWT-640: PrivacySlasher Contract
Calls EigenLayer's InstantSlasher.fulfillSlashingRequest() when a violation is proven. References EpochRegistry.isEpochActive() and
getEpoch() to validate that the slashing claim is against a valid epoch.
Why Phase 2D Matters
Without Phase 2D, the privacy system is trust-based, not trust-minimized.
Right now, operators can:
- Submit garbage partial decryptions — the gateway detects this via DLEQ verification and excludes the operator, but the operator faces
zero economic consequences. They can keep doing it. - Refuse to participate — a malicious operator can selectively drop privacy tasks (censorship) with no penalty. As long as t-of-n others
participate, the task succeeds, but the attack is free. - Participate in unauthorized decryptions — if an operator's key share is compromised, there's no on-chain mechanism to prove misuse.
The practical impact:
- For users: Privacy data is protected by operator honesty, not economics. An operator could collude to reconstruct the master secret
(needs t operators) with no slashing risk. - For the protocol: Can't credibly claim "restaked security" for privacy operations. EigenLayer's value proposition is economic security
— without slashing, privacy is outside that model. - For auditors: A security audit would flag "no slashing for privacy violations" as a critical finding.
How Critical is Phase 2D?
For testnet/staging: Not blocking. The current system works correctly — DLEQ proofs verify off-chain, bad operators are excluded from
aggregation, and the permissioned operator set provides practical security.
For mainnet with real money: Phase 2D is a prerequisite. You can't launch privacy features with restaked ETH security claims unless
misbehavior has economic consequences. The DLEQ proof is the key differentiator from other threshold encryption systems — it makes
wrong-keyshare provable on-chain, not just detectable.
For the DAO transition: The PrivacySlasher is permissionless (anyone can submit a slashing proof). This aligns with decentralization —
you don't need a trusted entity to police operators.
Recommendation
Phase 2D is necessary for production but not urgent for the current testnet milestone. The natural order:
- Deploy EpochRegistry to stagef/testnet (just merged)
- Run PSS epoch rotation in production for a few cycles to validate stability
- Implement Phase 2D (NEWT-629 → NEWT-641 → NEWT-640) before mainnet privacy launch
- Security audit should cover both the Rust crypto (PSS, DLEQ) and the Solidity slashing contracts together
The DLEQ verification is the hard part — it requires mapping the Ristretto255 DLEQ proof to bn128 pairing-friendly operations on-chain,
which involves non-trivial curve arithmetic. That's why NEWT-641 exists as a separate library.
✻ Sautéed for 52s
❯ How does privacy slashing work in had with the current slashing logic we have? For example, if policy evaluation was correct, but
privacy decryption was incorrect, who get's slashed? is it just privacy decyrption operated operators?
⏺ ★ Insight ─────────────────────────────────────
This is a critical trust model question. Newton currently has two independent slashing mechanisms, and Phase 2D adds a third.
Understanding how they compose is essential for the spec.
─────────────────────────────────────────────────
Current Slashing Architecture
Let me check how the existing slashing works to give a precise answer:
Searched for 2 patterns (ctrl+o to expand)
⏺ Now I have the full picture. Here's how the two slashing systems interact:
Current Slashing: Policy Evaluation Correctness
The existing system slashes operators for incorrect policy evaluation results:
- raiseAndResolveChallenge — A challenger re-evaluates the policy independently (including generating a ZK proof via SP1). If the
operator's evaluationResult was wrong, ChallengeLib.slashSigningOperators() slashes all operators who BLS-signed the incorrect response
at 10% (WADS_TO_SLASH = 100000000000000000). - challengeDirectlyVerifiedAttestation — Same mechanism for the direct attestation path.
- slashForCrossChainChallenge — Cross-chain relay of a challenge from a destination chain.
Who gets slashed: Every operator whose BLS signature is in the aggregated response. The BLS aggregation means all signers are
collectively liable — you can't distinguish which individual operator produced a wrong sub-result.
Phase 2D: Privacy Decryption Correctness
Privacy slashing is fundamentally different from policy slashing because it targets a different failure mode:
┌─────────────────┬───────────────────────────────────────┬─────────────────────────────────────────────────┐
│ Aspect │ Policy Slashing (current) │ Privacy Slashing (Phase 2D) │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ What failed │ Policy evaluation result was wrong │ Threshold decryption share was wrong or missing │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ Who is liable │ All BLS signers (collective) │ Individual operator (targeted) │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ Proof type │ ZK proof of correct evaluation (SP1) │ DLEQ proof of correct partial DH │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ Detection │ Challenger re-evaluates independently │ Gateway verifies each operator's DLEQ proof │
├─────────────────┼───────────────────────────────────────┼─────────────────────────────────────────────────┤
│ When it happens │ After respondToTask on-chain │ During Prepare phase (off-chain, before BLS) │
└─────────────────┴───────────────────────────────────────┴─────────────────────────────────────────────────┘
The Key Interaction: Your Scenario
"Policy evaluation correct, but privacy decryption incorrect"
This is the exact scenario Phase 2D addresses. Here's the timeline:
- Prepare phase: Gateway asks operators for partial DH outputs. Operator A submits a wrong partial decryption with an invalid DLEQ
proof. - Gateway detects it: The Rust DLEQ verification (crates/core/src/dkg/dleq.rs) rejects Operator A's contribution. Gateway excludes
Operator A and proceeds with the remaining t-1 operators (if enough remain). - Commit phase: Policy evaluation happens with the correct decrypted data (from honest operators). BLS aggregation succeeds. The policy
result is correct. - On-chain: respondToTask lands. No policy challenge is raised (the result was correct).
Without Phase 2D: Operator A submitted a bad share, was excluded, and faces zero consequences. They can keep doing this — degrading
liveness (fewer operators available for threshold) without any economic risk.
With Phase 2D: The gateway submits Operator A's invalid DLEQ proof to the PrivacySlasher contract. The DleqVerifier library verifies the
proof is indeed invalid on-chain. Operator A gets slashed 10%. Only Operator A — not the other honest operators.
Separation of Concerns
The two slashing systems are independent and composable:
Policy slashing: BLS signers of wrong result → 10% slash (collective)
Privacy slashing: Individual bad decryptor → 10% slash (targeted)
An operator could be slashed by both systems in theory:
- They submit a wrong partial decryption AND sign a wrong policy result
- Privacy slash hits them individually for the bad share
- Policy slash hits all signers (including them) for the bad result
But in practice, the bad decryption is caught in the Prepare phase (before BLS signing), so the operator is excluded before they can sign
a wrong result. The two failure modes are temporally separated.
What About "No-Keyshare" (Liveness)?
The 1-2% slash for absence is lighter because:
- The operator might be legitimately down (network issues)
- It only matters if their absence drops below threshold (n-absent < t)
- Detection is via quorum attestation (t operators attest "Operator A didn't respond"), not cryptographic proof
This is similar to how EigenLayer handles general liveness failures, just scoped to the privacy decryption window.
✻ Crunched for 1m 21s
❯ But in this model, Gateway is being trusted that it will honestly submit onchain to slash the bad operators. Isn't it? is there a way
for the current challenger to do so?
⏺ You've identified the critical trust assumption. Yes — in the current Phase 2D design, the gateway is the only entity that sees the raw
DLEQ proofs during the Prepare phase. If the gateway is compromised or colluding with a bad operator, it can simply not submit the
slashing proof.
The Trust Gap
┌───────────────────────────────┬─────────────────────────┬──────────────────┬─────────────────────────────────────────────────────┐
│ Slashing Type │ Who Detects │ Who Submits │ Trust Assumption │
│ │ │ Proof │ │
├───────────────────────────────┼─────────────────────────┼──────────────────┼─────────────────────────────────────────────────────┤
│ Policy (current) │ Challenger │ Challenger │ Trustless — anyone can re-evaluate and challenge │
│ │ (independent) │ │ │
├───────────────────────────────┼─────────────────────────┼──────────────────┼─────────────────────────────────────────────────────┤
│ Privacy (Phase 2D as │ Gateway │ Gateway │ Trusted — gateway must be honest about DLEQ │
│ designed) │ │ │ failures │
└───────────────────────────────┴─────────────────────────┴──────────────────┴─────────────────────────────────────────────────────┘
The current challenger cannot detect privacy decryption failures because:
- The challenger doesn't participate in the Prepare phase — it only sees the final respondToTask on-chain
- The DLEQ proofs are exchanged off-chain between operators and the gateway
- By the time the task response is on-chain, the bad operator was already excluded and the result is correct
Could the Challenger Do It?
For the challenger to independently verify privacy decryption correctness, it would need:
Option A: On-chain DLEQ commitments (NEWT-630)
This is actually what NEWT-630 (on-chain aggregation commitments) is designed for. If the gateway publishes a commitment of all partial
decryptions + DLEQ proofs on-chain (or to EigenDA), then:
- Gateway posts commitmentRoot = keccak256(operator_id, D_i, pi_i) per task
- Challenger can request the individual proofs from EigenDA/gateway
- Challenger verifies each DLEQ proof independently
- If a proof is invalid, challenger submits slashing proof to PrivacySlasher
This makes privacy slashing trustless — same model as policy slashing. The gateway can't hide a bad operator because the commitment is
on-chain.
Option B: Operators report each other
In the Prepare phase, each operator receives partial decryptions from peers (in a broadcast model). If Operator B sees Operator A's
invalid DLEQ proof, Operator B could submit the slashing proof directly. But currently the gateway mediates all communication — operators
don't see each other's proofs.
Option C: Broadcast partial decryptions + proofs publicly
Post all DLEQ proofs to a public data availability layer. Anyone can verify. But this leaks partial decryption data which has privacy
implications.
The Right Architecture
The combination of NEWT-630 (commitments) + NEWT-640 (PrivacySlasher) makes it trustless:
Prepare phase:
Gateway collects partials + DLEQ proofs from operators
Gateway verifies DLEQ proofs (excludes bad operators)
Gateway posts commitment root on-chain (NEWT-630)
Challenge window:
Challenger fetches individual proofs via EigenDA or gateway
Challenger verifies each DLEQ proof independently
If invalid: submits to PrivacySlasher (NEWT-640)
This is why NEWT-630 exists alongside NEWT-640/641 — without the commitments, the slashing is gateway-trusted. With commitments, any
external party can verify and slash, matching the existing challenger model for policy evaluation.
Practical Recommendation
For the implementation order, I'd suggest:
- NEWT-641 (DleqVerifier library) — pure cryptographic library, no trust assumptions
- NEWT-630 (on-chain commitments) — makes DLEQ proofs publicly verifiable
- NEWT-640 (PrivacySlasher) — references both DleqVerifier and commitments
- NEWT-629 (spec update) — refine the spec to require commitment-based detection, not gateway-based
This way the slashing system is trustless from day one, matching Newton's existing challenger model. Want me to update the Linear tickets
to reflect this dependency ordering and the commitment requirement?
✻ Brewed for 31s
❯ yes
Called linear-server (ctrl+o to expand)
⏺ Good — NEWT-630 already has the commitment architecture described in detail, and it already blocks NEWT-629. But the description needs
updating to reflect the DLEQ-based trustless slashing model we just discussed. Let me also check NEWT-629's current relations:
Called linear-server (ctrl+o to expand)
⏺ Good — the dependency graph already has NEWT-630 blocking NEWT-629, and NEWT-628 blocking NEWT-629 (done). Now let me update the tickets
to reflect the trustless slashing model and correct implementation order:
Called linear-server (ctrl+o to expand)
⏺ Now add the blocking relation — NEWT-641 should block NEWT-640 (PrivacySlasher needs DleqVerifier):
Called linear-server (ctrl+o to expand)
⏺ Now add NEWT-630 blocking NEWT-640 (PrivacySlasher needs commitments for trustless detection):
Called linear-server (ctrl+o to expand)
⏺ Now update NEWT-640 (PrivacySlasher) to reflect that it depends on both NEWT-641 and NEWT-630:
Called linear-server (ctrl+o to expand)
⏺ All 4 tickets updated. Here's the final dependency graph:
NEWT-628 (PSS epoch rotation) ──── DONE
└── NEWT-639 (EpochRegistry) ──── DONE
NEWT-641 (DleqVerifier library) ──── Backlog
└── blocks NEWT-640
NEWT-630 (On-chain commitments) ──── Backlog
├── blocks NEWT-629
└── blocks NEWT-640
NEWT-629 (Slashing spec) ──── Backlog, blocked by NEWT-628 (done) + NEWT-630
├── NEWT-640 (PrivacySlasher) ──── Backlog, blocked by NEWT-641 + NEWT-630
└── NEWT-641 (DleqVerifier) ──── Backlog
Implementation order:
- NEWT-641 — DleqVerifier library (no blockers, pure crypto)
- NEWT-630 — On-chain commitments (no blockers, extends TaskResponseMetadata)
- NEWT-640 — PrivacySlasher (blocked by both above)
- NEWT-629 — Spec finalization (refine based on implementation)
Key updates made:
- NEWT-629: Rewrote to emphasize trustless detection model, removed Tier 3 plaintext leakage (deferred to TEE), added policy-vs-privacy
slashing comparison table - NEWT-640: Updated with permissionless design, explicit DleqVerifier + commitments dependency, slashing flow for both violation types
- NEWT-641: Added blocks: NEWT-640 relation
- NEWT-630: Added blocks: NEWT-640 relation
✻ Sautéed for 2m 9s
❯ one more question. is PrivacySlasher purely for Prepare phase?
⏺ No — it covers both phases, but different violation types map to different phases:
┌──────────────────────────┬───────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│ Violation │ Phase │ Why │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Wrong-keyshare (DLEQ │ Prepare │ Operators submit partial DH outputs during Prepare. The DLEQ proof accompanies each │
│ failure) │ phase │ partial. │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ No-keyshare (liveness) │ Prepare │ Operator fails to respond during the Prepare phase timeout. │
│ │ phase │ │
├──────────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Wrong BLS signature on │ Commit │ Operator BLS-signs a policy result that was computed from incorrect decrypted data. But │
│ privacy result │ phase │ this is already covered by the existing policy challenger (ZK proof) — not PrivacySlasher's │
│ │ │ scope. │
└──────────────────────────┴───────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘
So practically, the PrivacySlasher targets Prepare phase misbehavior — that's where threshold decryption happens. The Commit phase
(policy evaluation + BLS signing) is already covered by the existing ChallengeVerifier.
There's one edge case worth noting: in the gateway-never-decrypts model, operators also decrypt locally during the Commit phase (they
receive partial DH outputs from Prepare and complete HPKE decryption). But that local decryption is deterministic given correct partials
— if the partials were valid (verified via DLEQ in Prepare), the Commit-phase decryption can't be wrong. So there's no separate
Commit-phase privacy violation to slash for.
The flow:
Prepare phase: partials + DLEQ proofs → PrivacySlasher can slash
Commit phase: policy eval + BLS sign → ChallengeVerifier can slash (existing)
Two independent slashing systems, one per phase. No overlap, no gaps.
✻ Cogitated for 30s
❯ Let's proceed to next steps
⏺ The natural next step is NEWT-641 (DleqVerifier library) — it's the first in the dependency chain, has no blockers, and is pure
cryptographic library code.
But before diving into implementation, there's a significant design challenge worth discussing: the curve mismatch. Our DLEQ proofs are
generated on Ristretto255 (Rust side), but EVM precompiles (EIP-196) operate on alt_bn128 (BN254). These are fundamentally different
curves — you can't directly verify a Ristretto255 DLEQ proof using BN254 precompiles.
This needs a /grill-me before implementation. The ticket mentions two approaches:
- Proof translation — operator generates on Ristretto255, translates for BN254 verification
- Dual-curve commitment — operator commits public key share on both curves during DKG
There may be a third option worth exploring: verifying the DLEQ proof in a ZK circuit (SP1) instead of via EVM precompiles — we already
have the SP1 infrastructure for policy challenges.
Want to start with /grill-me on the DleqVerifier curve strategy, or would you prefer to tackle something else first?
✻ Crunched for 31s
❯ yes /grill-me on the DleqVerifier curve strategy
⏺ Skill(grill-me)
⎿ Successfully loaded skill
⏺ Let me first understand the exact DLEQ proof structure we're working with on the Rust side.
Read 1 file (ctrl+o to expand)
⏺ Good — now I have the exact DLEQ structure. The proof operates on Ed25519 (Curve25519 in Edwards form), not Ristretto255 as I initially
said. The proof is:
- c = SHA-256("newton-dleq-v1" || G || A || H || B || R1 || R2) mod order
- z = k - c * secret
- Verification: zG + cA == R1 and zH + cB == R2
This is standard Schnorr-style DLEQ on Ed25519 (group order ~2^252). EVM precompiles operate on BN254 (alt_bn128, group order ~2^254).
These are different curves with different group orders, base points, and coordinate systems.
Question 1 of N: Which verification approach should we use?
Three options with fundamentally different trade-offs:
A) Native Solidity verification on Ed25519
Implement Ed25519 point arithmetic in pure Solidity — scalar multiplication via double-and-add, point addition, SHA-256 hash. No
precompiles needed (SHA-256 is available as a precompile at address 0x02). But Ed25519 scalar mul in Solidity is extremely expensive —
~1M+ gas per verification (no native precompile for Curve25519).
B) SP1 ZK circuit verification
The DLEQ verification runs inside an SP1 zkVM circuit (we already have SP1 infrastructure for sp1-rego policy challenges). The circuit
verifies the DLEQ proof off-chain and produces a succinct proof. On-chain, only the SP1 proof is verified (~300k gas via the SP1 verifier
contract). The operator's DLEQ proof correctness is reduced to "verify this ZK proof."
C) Dual-curve scheme
During DKG/PSS, operators commit their public key share on both Ed25519 (for HPKE threshold decryption) and BN254 (for on-chain DLEQ
verification). Operators generate a second DLEQ proof on BN254 alongside the Ed25519 one. The BN254 proof can be verified on-chain using
EIP-196/197 precompiles (~40k gas). But this doubles the DKG key material and requires operators to maintain two key shares.
My recommendation: Option B (SP1 ZK circuit). Reasons:
- We already have SP1 infrastructure (circuits/sp1-rego/) and the SP1 verifier deployed on-chain
- No changes to the DKG/PSS ceremony or key material
- The DLEQ verification is simple enough for an SP1 circuit (~100 lines)
- Gas cost is moderate (~300k) but acceptable for a slashing path (not hot path)
- The Ed25519 arithmetic runs natively inside SP1's RISC-V environment (no field translation)
- Aligns with the existing challenger pattern: challenger generates ZK proof, submits on-chain
A, B, or C?
✻ Churned for 51s
❯ How complex is C? "But this doubles the DKG key material and requires operators to maintain two key shares." --> what does this entail
in terms of complexity, error-prone-ness, reliability, efficiency?
⏺ Good question. Let me break down exactly what dual-curve entails:
What "Two Key Shares" Means Concretely
Currently each operator has:
- One secret scalar s_i (a 32-byte number)
- One public share pk_i = s_i * G_ed25519 (Ed25519 point)
With dual-curve, each operator would have:
- Same secret scalar s_i (it's just a number — curve-agnostic)
- Ed25519 public share: pk_i_ed = s_i * G_ed25519
- BN254 public share: pk_i_bn = s_i * G_bn254
The secret scalar is the same — you don't need two separate secrets. You just compute the public share on both curves. The scalar s_i is
~252 bits; both Ed25519 (order ~2^252) and BN254 (order ~2^254) can accommodate it.
Complexity Assessment
DKG ceremony changes:
- frost.rs round3: after computing s_i, also compute s_i * G_bn254 and store it
- keystore.rs: store both public shares in the encrypted keystore
- types.rs: add bn254_public_share: [u8; 64] to KeyShare (BN254 points are 64 bytes uncompressed)
- FROST package broadcast: include the BN254 public share in round3 output so all parties know it
~50 lines of code change in the DKG crate. Not trivial but not massive.
PSS refresh changes:
- refresh.rs: Feldman commitments are currently Ed25519 only. Would need BN254 commitments too (or derive from the same polynomial, since
coefficients are scalars) - Actually — the polynomial coefficients are scalars, and Feldman verification is share * G == sum(C_j * x^j). If we compute Feldman
commitments on both curves, verification works on both independently - ~30 lines additional in refresh.rs
Operator runtime changes:
- During Prepare phase: operator generates DLEQ proof on BN254 instead of (or in addition to) Ed25519
- Requires a BN254 scalar multiplication library in Rust (e.g., ark-bn254 or alloy's BN254 support)
- New dependency in the operator crate
On-chain changes:
- EpochRegistry or a companion contract stores BN254 public shares per operator per epoch
- Additional ~21k gas per operator per epoch for storage
- DleqVerifier uses EIP-196 ecMul + ecAdd: ~12k gas for verification (very cheap)
Error-Proneness
The risky part: the scalar s_i must be valid in both group orders. Ed25519 order is 2^252 + 27742... and BN254 order is 21888...
(~2^254). Since the BN254 order is larger, any valid Ed25519 scalar is also valid for BN254. This works safely — no modular reduction
needed.
But: if we ever change the DKG to generate scalars mod BN254's order (larger), they might not be valid Ed25519 scalars. This is a latent
footgun. The rule would be: always generate scalars mod Ed25519 order (the smaller one).
FROST interaction: FROST DKG on Ristretto255 generates scalars mod the Ristretto255 group order (same as Ed25519). These are guaranteed
valid for BN254. No issue with the current FROST implementation.
Reliability
- One more thing that can go wrong during DKG: BN254 key derivation failure
- One more field to serialize/deserialize in keystore
- One more commitment to verify during PSS refresh
- But all of these are deterministic — if the scalar is valid (which it is by construction), the BN254 operations can't fail
Efficiency
┌───────────────────────┬──────────────────────────────┬────────────────────────────────────────────────────┐
│ Aspect │ Current (Ed25519 only) │ Dual-Curve │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ DKG ceremony │ 3 rounds │ 3 rounds (same, +1 point computation per operator) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ Keystore size │ ~200 bytes │ ~264 bytes (+64 bytes for BN254 point) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ PSS refresh │ 1 set of Feldman commitments │ 2 sets (but can share polynomial) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ Prepare phase │ 1 DLEQ proof (Ed25519) │ 1 DLEQ proof (BN254) — can drop Ed25519 proof │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ On-chain verification │ N/A │ ~12k gas (ecMul precompile) │
├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────────┤
│ On-chain storage │ 0 │ ~21k gas per operator per epoch │
└───────────────────────┴──────────────────────────────┴────────────────────────────────────────────────────┘
Compared to SP1 (Option B)
┌───────────────────────────┬───────────────────────────┬───────────────────────────────────┐
│ Aspect │ SP1 ZK Circuit │ Dual-Curve │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ On-chain verification gas │ ~300k (SP1 verifier) │ ~12k (ecMul precompile) │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ Proof generation time │ ~10-30s (SP1 prover) │ ~0ms (BN254 point mul is instant) │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ New dependencies │ None (SP1 already exists) │ ark-bn254 or similar in operator │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ DKG changes │ None │ ~80 lines │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ Slashing latency │ Slow (proof generation) │ Instant (precompile verification) │
├───────────────────────────┼───────────────────────────┼───────────────────────────────────┤
│ Complexity locus │ Circuit code │ DKG + keystore + refresh │
└───────────────────────────┴───────────────────────────┴───────────────────────────────────┘
My Updated Assessment
Dual-curve is more complex upfront (~80 lines across DKG/PSS/keystore) but dramatically cheaper and faster at verification time (12k gas
vs 300k, instant vs 10-30s proof generation). The complexity is contained in well-tested crypto code (scalar multiplication is
deterministic), not in a new ZK circuit.
SP1 is simpler to implement (no DKG changes) but slower and more expensive when actually slashing.
Revised recommendation: If slashing speed matters (a bad operator should be slashable immediately, not after waiting for SP1 proof
generation), dual-curve is better. If implementation speed matters and slashing is rare, SP1 is better.
What's your priority — fast implementation or fast slashing?
✻ Cooked for 45s
❯ reliability and least error-prone (technically and operationally). Least execution risk.
⏺ With that criteria, Option B (SP1 ZK circuit) is the clear winner.
Here's why:
Dual-curve (C) execution risks:
- Modifying the DKG ceremony is high-stakes — FROST is the most complex crypto in the codebase. Any bug in round3 BN254 key derivation
could produce invalid key material across all operators, requiring a fresh DKG ceremony to recover - PSS refresh with dual Feldman commitments doubles the verification surface — a bug means operators silently accumulate invalid BN254
shares across epochs - The "scalar valid in both group orders" invariant is a latent footgun that every future contributor must know about
- New ark-bn254 dependency in the operator crate — different serialization format, different point representation, new attack surface
- Every operator must upgrade simultaneously to support BN254 — partial rollout breaks the ceremony
SP1 (B) execution risks:
- The DLEQ verification logic is ~30 lines of Rust (same code as dleq.rs::verify(), compiled to RISC-V)
- No changes to DKG, PSS, keystore, or operator runtime
- No new dependencies — curve25519-dalek already works in SP1's RISC-V target
- The SP1 verifier contract is already deployed and battle-tested on-chain
- Slashing is rare (dispute path, not hot path) — 10-30s proof generation is acceptable
- If the SP1 circuit has a bug, fix and redeploy the circuit — no operator coordination needed
The decisive factor: with dual-curve, a bug corrupts live key material across all operators. With SP1, a bug means a slashing proof fails
to verify — fixable without touching the privacy system.
Let me continue the grill.
Question 2 of N: SP1 circuit scope — verify a single DLEQ proof, or verify the full commitment?
Two circuit designs:
A) Single DLEQ verification: The circuit takes one operator's DLEQ proof + public inputs (G, pk_i, H, D_i) and outputs pass/fail. The
challenger generates one SP1 proof per operator they want to slash.
B) Commitment batch verification: The circuit takes the full commitment from NEWT-630 (all operator partials + DLEQ proofs for a task)
and verifies all of them, outputting a bitmap of which operators' proofs are invalid. One SP1 proof covers the entire task.
My recommendation: Option A (single DLEQ). Simpler circuit, faster proof generation (one operator's proof vs all), and the PrivacySlasher
only needs to slash one operator at a time. Batch can be added later if needed.
A or B?
✻ Brewed for 30s
❯ B) Commitment batch verification: The circuit takes the full commitment from NEWT-630 (all operator partials + DLEQ proofs for a task)
and verifies all of them, outputting a bitmap of which operators' proofs are invalid. One SP1 proof covers the entire task. -->
similar to how we slashing in ChallengeVerifier
⏺ Exactly — matches the existing pattern in ChallengeLib.slashSigningOperators() which slashes all bad signers in one batch. Consistent
architecture.
Question 3 of N: What are the public inputs and outputs of the SP1 circuit?
The circuit needs to:
- Take in all DLEQ proofs for a task
- Verify each against the operator's committed public share
- Output which operators failed
Public inputs (committed on-chain, verifiable by anyone):
- decryptionCommitment (bytes32) — from NEWT-630, the on-chain commitment hash
- epochId (uint64) — which epoch's key shares to verify against
- taskId (bytes32) — which task's partial decryptions
Private inputs (provided by the challenger, not on-chain):
- Per-operator: (operator_index, pk_share, enc_point, partial_decryption, dleq_proof)
- These are fetched from EigenDA or the gateway's data availability layer
Public outputs (written to the proof, verified on-chain):
- invalidOperatorBitmap (uint256) — bitmask of which operators submitted invalid DLEQ proofs
- commitmentHash (bytes32) — re-computed from the private inputs, must match the on-chain decryptionCommitment
The on-chain PrivacySlasher then:
- Verifies the SP1 proof
- Checks commitmentHash == stored decryptionCommitment (binding)
- Slashes each operator flagged in invalidOperatorBitmap
My recommendation: This structure directly. The commitmentHash binding prevents the challenger from fabricating inputs — they must use
the same data the gateway committed to.
Does this input/output structure look right, or do you want to adjust?
✻ Cogitated for 36s
❯ looks right
⏺ Question 4 of N: Where does the challenger get the raw DLEQ proofs (private inputs)?
The on-chain commitment (NEWT-630) stores only a hash. The challenger needs the actual per-operator data to feed into the SP1 circuit.
Three options:
A) EigenDA: Gateway posts the raw DLEQ data to EigenDA alongside the on-chain commitment hash. EigenDA provides data availability
guarantees — the data is retrievable by anyone for the dispute window. Cost: ~$0.01 per blob. This is how EigenLayer-native systems
typically handle DA.
B) Gateway API endpoint: Gateway exposes a newt_getDecryptionProofs(taskId) RPC endpoint that returns the raw data. Simpler but relies on
the gateway being online and honest about serving the data. A compromised gateway could refuse to serve proofs for tasks where it
colluded.
C) Operator-direct: Challenger contacts each operator directly (via their socket address) to re-request their partial decryption + DLEQ
proof. Operators are incentivized to respond (if they're honest, they want the bad operator slashed). No single point of failure.
My recommendation: Option A (EigenDA). Reasons:
- The gateway is already submitting BLS data on-chain — adding a DA blob is a small incremental cost
- Removes the gateway as a trust dependency for data retrieval (matches our "trustless" goal)
- The challenger doesn't need to contact operators individually (they might be unreachable)
- EigenDA is the natural DA layer for an EigenLayer AVS
- If EigenDA is unavailable, Option C (operator-direct) serves as a fallback
A, B, or C?
looks right
⏺ Question 4 of N: Where does the challenger get the raw DLEQ proofs (private inputs)?
The on-chain commitment (NEWT-630) stores only a hash. The challenger needs the actual per-operator data to feed into the SP1 circuit.
Three options:
A) EigenDA: Gateway posts the raw DLEQ data to EigenDA alongside the on-chain commitment hash. EigenDA provides data availability
guarantees — the data is retrievable by anyone for the dispute window. Cost: ~$0.01 per blob. This is how EigenLayer-native systems
typically handle DA.
B) Gateway API endpoint: Gateway exposes a newt_getDecryptionProofs(taskId) RPC endpoint that returns the raw data. Simpler but relies on
the gateway being online and honest about serving the data. A compromised gateway could refuse to serve proofs for tasks where it
colluded.
C) Operator-direct: Challenger contacts each operator directly (via their socket address) to re-request their partial decryption + DLEQ
proof. Operators are incentivized to respond (if they're honest, they want the bad operator slashed). No single point of failure.
My recommendation: Option A (EigenDA). Reasons:
- The gateway is already submitting BLS data on-chain — adding a DA blob is a small incremental cost
- Removes the gateway as a trust dependency for data retrieval (matches our "trustless" goal)
- The challenger doesn't need to contact operators individually (they might be unreachable)
- EigenDA is the natural DA layer for an EigenLayer AVS
- If EigenDA is unavailable, Option C (operator-direct) serves as a fallback
A, B, or C?
❯ Agreed. EigenDA
⏺ Question 5 of N: Should the SP1 DLEQ circuit be a new circuit or extend the existing sp1-rego circuit?
Currently we have one SP1 circuit at circuits/sp1-rego/ that verifies Rego policy evaluation for the existing challenger. Two options:
A) New separate circuit (circuits/sp1-dleq/): Independent binary, independent proof key, independent verifier on-chain. Clean separation
— DLEQ verification has nothing to do with Rego policy evaluation.
B) Extend sp1-rego: Add a "mode" flag — the circuit either verifies Rego policy OR verifies DLEQ proofs depending on the input. Shares
the same SP1 verifier contract on-chain.
My recommendation: Option A (separate circuit). The two proofs verify completely different things — Rego policy correctness vs DLEQ
cryptographic validity. Combining them into one circuit adds complexity for no benefit. The SP1 verifier contract on-chain can verify
proofs from any circuit (it's parameterized by vkey), so there's no deployment overhead from having two circuits.
A or B?
❯ a
⏺ Question 6 of N: How does the challenger know something is wrong in the first place?
The challenger needs a trigger to investigate a task's DLEQ proofs. It won't speculatively verify every task's proofs via SP1 (too
expensive). Three detection models:
A) Verify all — optimistic: The challenger fetches DLEQ data from EigenDA for every privacy-enabled task and runs off-chain DLEQ
verification (cheap Rust code, no SP1). Only when it detects an invalid proof does it generate the expensive SP1 proof for on-chain
submission. This is the same pattern as the existing policy challenger — it re-evaluates every task, only challenges when results differ.
B) Selective — commitment-based: The challenger only investigates tasks where the gateway's on-chain commitment includes an operator that
was previously flagged as suspicious (reputation system). Reduces work but could miss first-time attackers.
C) Reactive — external report: Anyone can tip the challenger (via an RPC endpoint or on-chain bounty) that a specific task has bad
proofs. The challenger then investigates that specific task.
My recommendation: Option A (verify all, optimistic). Reasons:
- Off-chain Ed25519 DLEQ verification is ~microseconds per proof — trivially cheap for the challenger to verify every privacy task
- Matches the existing challenger architecture: verify everything off-chain, only go on-chain when a violation is found
- No reputation system or tipping infrastructure needed
- The SP1 proof generation (expensive part) only happens on the rare slashing path
The challenger flow becomes:
- Watch EpochRegistered and respondToTask events
- For each privacy-enabled task: fetch DLEQ data from EigenDA
- Run dleq::verify() in Rust for each operator's proof (~microseconds)
- If any fail: generate SP1 batch proof and submit to PrivacySlasher
A, B, or C?
❯ option A
⏺ Question 7 of N: Timing — when does the commitment get posted?
The DLEQ proofs happen during the Prepare phase, but the on-chain transaction happens at respondToTask (end of Commit phase). The
commitment needs to be included in the on-chain submission. Two options:
A) Embed in respondToTask: Add decryptionCommitment and operatorBitmap as new fields in TaskResponseMetadata. The commitment travels with
the existing BLS-aggregated response — one transaction, no extra on-chain call. The gateway computes the commitment during the Prepare
phase and carries it through to submission.
B) Separate transaction: Gateway submits registerDecryptionCommitment(taskId, commitment, bitmap) as a separate call after respondToTask.
Two transactions per privacy task. More gas, but doesn't require modifying the TaskManager interface.
My recommendation: Option A (embed in respondToTask). Modifying TaskResponseMetadata is a struct change that affects the ABI selector
(per lessons.md — "struct changes silently break all callers"), but NEWT-630 is a planned breaking change anyway. Embedding avoids the
extra transaction cost (~21k gas saved per task) and ensures the commitment is atomically bound to the task response — no window where a
response exists without a commitment.
The trade-off: changing TaskResponseMetadata means all clients (gateway, challenger) must update their ABI. But this is a coordinated
deploy — same as any contract upgrade.
A or B?
❯ option A
⏺ Question 8 of N: What happens for non-privacy tasks?
Not every task uses threshold decryption. For tasks without privacy data, there are no DLEQ proofs to commit. The decryptionCommitment
field in TaskResponseMetadata needs to handle this.
A) Zero-value sentinel: decryptionCommitment = bytes32(0) and operatorBitmap = 0 for non-privacy tasks. The PrivacySlasher rejects
challenges against tasks with zero commitment. Simple, no branching in the contract.
B) Boolean flag: Add bool hasDecryptionCommitment to TaskResponseMetadata. Explicit but uses an extra storage slot.
C) Omit from non-privacy tasks: Use a separate respondToTaskWithPrivacy() function that includes the commitment fields. Non-privacy tasks
use the existing respondToTask(). Two code paths on both the gateway and contract side.
My recommendation: Option A (zero-value sentinel). Reasons:
- bytes32(0) is the natural default for unset storage — no extra logic needed
- One function signature for all tasks — the gateway always passes the commitment (zero for non-privacy)
- PrivacySlasher checks commitment != bytes32(0) before processing — one line
- No bool storage overhead, no function signature forking
A, B, or C?
❯ A
⏺ Question 9 of N: Challenge window — same 100 blocks as policy challenges, or different?
The existing ChallengeVerifier uses taskChallengeWindowBlock (configurable, time-normalized to ~360s across chains). Privacy slashing
could share the same window or have its own.
A) Same window: Both policy and privacy challenges must be submitted within taskChallengeWindowBlock of respondToTask. One window to
reason about. After it expires, the task is finalized — no privacy or policy challenges.
B) Separate window: Privacy challenges have their own privacyChallengeWindowBlock, potentially longer (DLEQ data needs to be fetched from
EigenDA + SP1 proof generated, which takes longer than a ZK policy re-evaluation). But adds configuration complexity.
My recommendation: Option A (same window). The SP1 proof generation for DLEQ (~10-30s) is comparable to or faster than the SP1 Rego
proof. The challenger runs off-chain DLEQ verification immediately (microseconds), only generates the SP1 proof when a violation is
found. The 360s window is more than enough for: detect violation (~1s) + generate SP1 proof (~30s) + submit transaction (~12s). Sharing
the window simplifies the mental model — after the window closes, the task is fully finalized.
A or B?
❯ A
⏺ I believe we've covered all the major design branches. Let me summarize.
DleqVerifier / Privacy Slashing Design Summary (9 decisions)
┌─────┬────────────────┬────────────────────────────────────────────────┬────────────────────────────────────────────────────────────┐
│ # │ Decision │ Choice │ Reasoning │
├─────┼────────────────┼────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ 1 │ Verification │ SP1 ZK circuit │ Least execution risk — no DKG/PSS changes, no new operator │
│ │ approach │ │ dependencies, bug in circuit doesn't corrupt key material │
├─────┼────────────────┼────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ │ │ │ Verifies all operator DLEQ proofs for a task, outputs │
│ 2 │ Circuit scope │ Batch verification │ invalid bitmap. Matches ChallengeLib.slashSigningOperators │
│ │ │ │ pattern │
│ │ │ │ ChallengeLib.slashSigningOperators pattern │
├─────┼────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ │ │ Public: commitmentHash + epochId + taskId. │ │
│ 3 │ Circuit I/O │ Output: invalidOperatorBitmap + re-computed │ Commitment binding prevents fabricated inputs │
│ │ │ commitmentHash for binding │ │
├─────┼────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ │ Data │ │ Gateway posts raw DLEQ data alongside on-chain │
│ 4 │ availability │ EigenDA │ commitment hash. Trustless retrieval, natural for │
│ │ │ │ EigenLayer AVS │
├─────┼────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 5 │ Circuit │ New circuits/sp1-dleq/ │ Separate from sp1-rego — different proof types, clean │
│ │ organization │ │ separation │
├─────┼────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ │ Detection │ │ Challenger runs off-chain DLEQ verify (~microseconds) on │
│ 6 │ model │ Verify all (optimistic) │ every privacy task, only generates SP1 proof when │
│ │ │ │ violation found │
├─────┼────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 7 │ Commitment │ Embed in respondToTask │ New fields in TaskResponseMetadata — atomic, one │
│ │ timing │ │ transaction, no commitment gap │
├─────┼────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 8 │ Non-privacy │ Zero-value sentinel │ decryptionCommitment = bytes32(0), PrivacySlasher │
│ │ tasks │ │ rejects challenges on zero commitment │
├─────┼────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 9 │ Challenge │ Same as policy (taskChallengeWindowBlock) │ ~360s is sufficient for detect + SP1 prove + submit. One │
│ │ window │ │ window simplifies finalization │
└─────┴────────────────┴──────────────────────────────────────────────────┴──────────────────────────────────────────────────────────┘
Implementation order refined:
- NEWT-641: sp1-dleq circuit (replaces Solidity DleqVerifier — SP1 verifier already deployed)
- NEWT-630: On-chain commitments in TaskResponseMetadata + EigenDA blob posting
- NEWT-640: PrivacySlasher contract — verifies SP1 proof, slashes via invalidOperatorBitmap
- Challenger extension: Fetch from EigenDA, off-chain DLEQ verify, SP1 proof on violation
Want me to save this as a plan and update the Linear tickets with the SP1 approach?
❯ How practical is PSS implementation approach solution for Proactive refresh where each node updates its share using a random
polynomial that keeps the same secret key?
⏺ Let me give you an honest engineering assessment.
★ Insight ─────────────────────────────────────
The PSS algorithm itself is simple — the hard part isn't the math, it's the integration with Newton's specific crypto stack
(Ristretto255 for DKG, X25519 for HPKE, the montgomery↔edwards conversion layer).
─────────────────────────────────────────────────
The Core Algorithm (Simple)
PSS refresh is conceptually straightforward:
Each operator i:
1. Generate random polynomial r_i(x) of degree t-1 where r_i(0) = 0
2. Compute commitments C_i = [r_i_0 * G, r_i_1 * G, ...]
3. Send r_i(j) to each operator j (encrypted)
4. Verify received shares against commitments (Feldman VSS check)
5. New share: sk'_i = sk_i + Σ_j r_j(i)
The key property: since every r_j(0) = 0, the sum Σ_j r_j(0) = 0, so the master secret is unchanged. MPK stays constant.
Newton-Specific Practical Challenges
Challenge 1: Curve mismatch
Newton's threshold scheme operates on Ristretto255 (for DLEQ proofs and Lagrange interpolation in dkg/combine.rs), but HPKE uses
X25519 (Montgomery curve). The existing montgomery_to_edwards() conversion bridge in combine.rs handles this for single DKG
ceremonies. PSS refresh polynomials must operate in the same group as the key shares (Ristretto255 scalars), and the refreshed shares
must still produce valid X25519 DH outputs after montgomery conversion.
This works mathematically — the scalar field is the same. But it needs testing.
Challenge 2: Existing building blocks
Newton already has most of the primitives in crates/core/src/dkg/:
┌─────────────────────────────────────────────┬─────────┬─────────────┐
│ Primitive │ Exists? │ File │
├─────────────────────────────────────────────┼─────────┼─────────────┤
│ Polynomial evaluation (Horner's method) │ Yes │ dealer.rs │
├─────────────────────────────────────────────┼─────────┼─────────────┤
│ Feldman VSS commitments │ Yes │ dealer.rs │
├─────────────────────────────────────────────┼─────────┼─────────────┤
│ Share verification against commitments │ Yes │ dealer.rs │
├─────────────────────────────────────────────┼─────────┼─────────────┤
│ Scalar arithmetic on Ristretto255 │ Yes │ combine.rs │
├─────────────────────────────────────────────┼─────────┼─────────────┤
│ DLEQ proofs for partial decryptions │ Yes │ dleq.rs │
├─────────────────────────────────────────────┼─────────┼─────────────┤
│ Encrypted share distribution (FROST rounds) │ Yes │ frost.rs │
├─────────────────────────────────────────────┼─────────┼─────────────┤
│ Keystore persistence │ Yes │ keystore.rs │
└─────────────────────────────────────────────┴─────────┴─────────────┘
What's missing is only:
Challenge 3: Estimated effort
Zero-constant polynomial gen: ~30 lines (modify dealer.rs generate_polynomial)
Share update accumulation: ~20 lines (scalar addition)
Commitment update: ~15 lines (point addition)
Refresh coordinator: ~200 lines (similar to DkgCoordinator but 1 round, not 3)
Refresh RPC handlers: ~150 lines (operator side)
Tests: ~300 lines
Total new crypto code: ~65 lines
Total new coordination code: ~650 lines
The crypto is trivial — it's just polynomial evaluation with a constrained constant term plus scalar/point addition. The coordination
code is the bulk of the work, and it's simpler than FROST (1 round of share exchange vs 3 rounds).
Practical Assessment
┌─────────────────────────┬─────────────────┬──────────────────────────────────────────────────────────────────────────┐
│ Dimension │ Rating │ Notes │
├─────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────────────────┤
│ Algorithm complexity │ Low │ Well-understood since 1995, simple scalar arithmetic │
├─────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────────────────┤
│ Implementation risk │ Low-Medium │ Uses existing primitives, ~65 lines of new crypto │
├─────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────────────────┤
│ Coordination complexity │ Medium │ 1-round protocol, simpler than FROST DKG │
├─────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────────────────┤
│ Audit scope │ Small │ Only the refresh polynomial + accumulation needs audit │
├─────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────────────────┤
│ Testing │ Straightforward │ Property: MPK unchanged after refresh, old shares can't combine with new │
├─────────────────────────┼─────────────────┼──────────────────────────────────────────────────────────────────────────┤
│ Production readiness │ Medium │ No off-the-shelf crate, but building blocks exist in our codebase │
└─────────────────────────┴─────────────────┴──────────────────────────────────────────────────────────────────────────┘
The Honest Bottom Line
PSS is more practical than I initially suggested. The ~65 lines of new crypto code are simple scalar/point operations — not complex
pairing math or novel constructions. The existing dealer.rs already does polynomial evaluation and Feldman VSS, so the refresh is a
constrained variant of what we already have.
The real question is: is constant MPK worth 2-3 extra weeks of work + a focused crypto review?
With fresh FROST DKG per epoch:
With PSS refresh:
The code sizes are comparable. PSS eliminates the entire re-encryption machinery (which has its own complexity and failure modes). The
tradeoff is: re-encryption orchestration complexity vs refresh protocol complexity + small crypto audit.
If you want to go straight to PSS instead of the two-step approach, it's practical. What's your preference now with this fuller
picture?
✻ Brewed for 46s
❯ yes then let's go straight to PSS. The re encryption approach won't scale operationally either.