Single Sign: One Visible Signature, Many Verifiable Digests

# Single Sign: One Visible Signature, Many Verifiable Digests > Aggregate many signature requests into one visible message the user signs once, then use RISC Zero proofs to show any individual EIP-712/191 digest came from that signed data — ERC-1271-compatible, so protocols validate digests with no extra popups. - Canonical URL: https://blog.eggtech.io/posts/single-sign - Markdown URL: https://blog.eggtech.io/posts/single-sign/index.md - Published: 2025-10-21 - Updated: 2025-10-21 - Author: Kai Aldag - Reading time: 10 min read - Tags: zk, ethereum, crypto ## Referenced Links - [GitHub](https://github.com/KaiCode2/single-sign) --- Crypto signing UX has a simple problem: the more useful an interaction becomes, the more signatures it tends to require. A swap might need an approval. A cross-chain action might need multiple permits. A smart account might need a typed authorization, a personal-message confirmation, and one or more protocol-specific payloads. Each request is individually reasonable, but the user experience turns into a stack of wallet popups. Single Sign changes the shape of that flow. Instead of asking the user to sign every request one by one, Single Sign aggregates many raw signature requests into one fully visible message. The user signs that aggregate once. Later, anyone can prove that a specific digest came from a slice of the originally signed data, without asking the user for another signature. The result is: - One user-visible signing step. - Many downstream EIP-712, EIP-191, or other signable digests. - A cryptographic proof that each digest existed inside the signed aggregate. - An ERC-1271-compatible contract surface for protocols that expect signature validation. The current repository demonstrates this with EIP-712 typed data, EIP-191 personal signing over the aggregate, RISC Zero proofs, and an ERC-1271 `SingleSign` contract. ## The Core Idea Single Sign separates the user authorization from the later digest verification. Normally, a protocol asks the user to sign exactly the digest it wants to verify. If there are ten protocol actions, there are ten signature prompts. Single Sign asks the user to sign one readable aggregate instead: ```text request_0 || request_1 || request_2 || ... || request_n ``` Each `request_i` is still the raw material needed to reconstruct a normal protocol digest. For EIP-712, that means the typed-data JSON containing `domain`, `types`, `primaryType`, and `message`. For EIP-191, it can be the personal-message payload. For another signable format, it can be any deterministic payload whose digest rules are known to the verifier program. The important design choice is that the user signs the actual aggregate bytes, not an opaque Merkle root. The signature is over the visible content. ```mermaid flowchart LR A["Raw sign request 0
EIP-712 Permit2"] --> D["Visible aggregate message"] B["Raw sign request 1
EIP-712 order"] --> D C["Raw sign request 2
EIP-191 attestation"] --> D D --> E["User signs once"] E --> F["Aggregate signature"] F --> G["Many later digest proofs"] ``` That makes the signing moment legible. The user can see the actual requests being authorized, in order, before producing one signature. ## From Many Requests To One Message The aggregation step has to be deterministic. Every byte matters. In the current implementation, the host takes multiple EIP-712 typed-data JSON objects, compacts them, concatenates them, and asks the user to sign the resulting byte string using EIP-191 personal-message semantics. Conceptually: ```text compact(request_0) = {"domain":...,"types":...,"primaryType":"PermitTransferFrom","message":...} compact(request_1) = {"domain":...,"types":...,"primaryType":"PermitTransferFrom","message":...} compact(request_2) = {"domain":...,"types":...,"primaryType":"PermitTransferFrom","message":...} aggregate = compact(request_0) || compact(request_1) || compact(request_2) signature = personal_sign(aggregate) ``` The host also records the byte ranges for each original request: ```text aggregate bytes 0 len |------------------|------------------|-----------------------------| request_0 request_1 request_2 [start_0,end_0) [start_1,end_1) [start_2,end_2) ``` In the repo, `common::find_concatenated_json_ranges` finds these `[start, end)` ranges by scanning the concatenated JSON string, tracking brace depth, and ignoring braces inside strings. The prover later uses one of these ranges to identify the slice whose digest should be reconstructed. ```mermaid flowchart TB subgraph Requests["Original requests"] R0["request_0
typed-data JSON"] R1["request_1
typed-data JSON"] R2["request_2
typed-data JSON"] end R0 --> C0["Compact bytes"] R1 --> C1["Compact bytes"] R2 --> C2["Compact bytes"] C0 --> AGG["Concatenate exact bytes"] C1 --> AGG C2 --> AGG AGG --> SIGN["EIP-191 sign aggregate"] SIGN --> SIG["One aggregate signature"] AGG --> RANGE["Range table
[start,end) per request"] ``` This gives Single Sign two pieces of evidence: - The aggregate signature proves the signer authorized the whole visible batch. - The byte range proves where a particular request lives inside that batch. ## Proving A Digest Exists In The Signed Data Once the user has signed the aggregate, downstream protocols do not need to receive another user signature. They need evidence that the digest they care about came from a request inside the signed aggregate. Single Sign proves that with a zkVM program. For one digest, the host provides the guest: - `signer`: the EOA expected to have signed the aggregate. - `signature`: the one signature over the full aggregate. - `typed_data_concat`: the aggregate bytes. - `digest_range`: the `[start, end)` byte range for the request being proven. Inside the guest, the program performs two checks: 1. Verify the aggregate signature against the full `typed_data_concat` bytes. 2. Slice `typed_data_concat[start..end]`, parse that slice, and recompute the digest. For the current EIP-712 path, the guest parses the slice as typed data and computes the standard EIP-712 signing hash: ```text digest = keccak256("\x19\x01" || domainSeparator || hashStruct(message)) ``` The guest commits only: ```text (signer, digest) ``` as public output. ```mermaid sequenceDiagram participant Host participant Guest as RISC Zero guest participant Verifier Host->>Guest: signer Host->>Guest: aggregate signature Host->>Guest: aggregate bytes Host->>Guest: digest range [start,end) Guest->>Guest: Verify EIP-191 signature over full aggregate Guest->>Guest: Extract aggregate[start..end] Guest->>Guest: Recompute request digest Guest->>Host: Receipt with journal (signer, digest) Host->>Verifier: Receipt, image ID, journal Verifier->>Verifier: Verify proof Verifier-->>Host: digest existed in signed aggregate ``` The proof binds three facts together: - This signer produced a valid signature over the aggregate. - This slice is part of that exact aggregate. - This digest is the digest obtained by applying the expected digest rules to that slice. That is the core Single Sign claim: a digest can be used later because it is proven to exist inside data the user already signed. ## Why Not Just Sign A Merkle Root? Merkle trees are useful, but they make the user's signing moment worse. If the wallet shows a Merkle root, the user sees a hash. The actual requests are somewhere else. The authorization is compact, but it is not naturally human-readable. Single Sign optimizes for a different property: the signed preimage is visible. The aggregate can be displayed as the actual list of typed-data requests, personal messages, and app-specific payloads. The later proof is what compresses the verification path, not the user-facing authorization. ```mermaid flowchart LR subgraph RootFlow["Root-based batching"] A1["Requests"] --> A2["Merkle root"] A2 --> A3["User signs hash"] A3 --> A4["Needs external context to understand"] end subgraph SingleSignFlow["Single Sign batching"] B1["Requests"] --> B2["Visible aggregate"] B2 --> B3["User signs readable bytes"] B3 --> B4["Proof compresses later verification"] end ``` The tradeoff is deliberate. Single Sign keeps the user's signature attached to the content itself, then uses zero-knowledge proofs to make later per-digest validation compact and protocol-compatible. ## EIP-712, EIP-191, And Other Signable Data The repo starts with EIP-712 because it is the most common format for structured protocol permissions. Permit2 is a good example: each permit has a typed domain, typed fields, a nonce, a deadline, a spender, and token permissions. Those fields are exactly what the user should be able to inspect. But the model is broader than EIP-712. A Single Sign aggregate can contain records for different signable formats as long as each record has deterministic digest rules: ```text record_0: EIP-712 typed data -> eip712_signing_hash(record_0) record_1: EIP-191 message -> personal_message_hash(record_1) record_2: Raw bytes -> keccak256(record_2) record_3: App-specific data -> app_defined_hash(record_3) ``` In production, a mixed-format aggregate should include explicit type tags or an envelope around each request so the verifier knows which digest function to apply: ```json { "kind": "eip712", "payload": { "domain": {}, "types": {}, "primaryType": "PermitTransferFrom", "message": {} } } ``` The current code path demonstrates the EIP-712 version of this idea. The same guest architecture can be extended with additional digest adapters for EIP-191 personal messages, raw hashes, transaction intents, account-abstraction user operations, or protocol-specific authorization formats. ```mermaid flowchart TB Slice["Slice from signed aggregate"] --> Kind{"Digest kind"} Kind -->|"EIP-712"| E712["Parse typed data
Compute EIP-712 hash"] Kind -->|"EIP-191"| E191["Apply personal-message prefix
Compute message hash"] Kind -->|"Raw32"| Raw["Use 32-byte digest directly"] Kind -->|"Custom"| Custom["Run app-specific digest rules"] E712 --> Out["Public journal
(signer, digest)"] E191 --> Out Raw --> Out Custom --> Out ``` The aggregation layer stays the same. Only the digest function changes. ## How The Contract Uses The Proof On-chain, Single Sign exposes the familiar ERC-1271 validation shape: ```solidity function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) ``` The `hash` is the digest a protocol wants to validate. The `signature` calldata is the RISC Zero seal proving that `(owner, hash)` was committed by the guest. The contract builds the expected journal: ```solidity bytes memory journal = abi.encode(owner(), hash); ``` Then it asks the RISC Zero verifier to verify the proof against the compiled guest image ID. If verification succeeds, `isValidSignature` returns the ERC-1271 magic value. If verification fails, it returns `0x00000000`. ```mermaid flowchart LR P["Protocol asks
isValidSignature(digest, proof)"] --> S["SingleSign contract"] S --> J["Build journal
abi.encode(owner, digest)"] J --> V["RISC Zero verifier"] V -->|"valid proof for image ID"| OK["Return ERC-1271 magic value"] V -->|"invalid proof"| NO["Return 0x00000000"] ``` To the protocol, this looks like signature validation. Under the hood, the "signature" is a proof that the digest was included in one larger signature the user already made. ## The End-To-End Flow Putting it all together: ```mermaid flowchart TB Dapp["Dapps / protocols
prepare sign requests"] --> Normalize["Normalize and compact
deterministic bytes"] Normalize --> Aggregate["Concatenate into one
visible aggregate"] Aggregate --> Wallet["Wallet displays aggregate
user signs once"] Wallet --> Sig["Aggregate EIP-191 signature"] Aggregate --> Ranges["Compute byte ranges
for each request"] Sig --> Prover["Prover"] Ranges --> Prover Prover --> Guest["zkVM guest verifies signature
and recomputes selected digest"] Guest --> Receipt["Receipt / proof
journal: (signer, digest)"] Receipt --> Contract["ERC-1271 SingleSign contract"] Contract --> Protocol["Protocol accepts digest
as signed by account"] ``` This is the main user experience improvement: 1. The user sees one aggregate. 2. The user signs once. 3. Each protocol can still verify the exact digest it expects. 4. The proof connects that digest back to the signed aggregate. ## What This Unlocks Single Sign is useful anywhere a product needs many authorizations but should not force the user through many signature prompts. Examples include: - Batch Permit2 approvals. - Multi-step trading or routing intents. - Cross-chain actions with one authorization per domain. - Smart-account sessions where one visible approval unlocks several bounded actions. - Protocol workflows that need ERC-1271 compatibility but want a better signing UX. The key is that Single Sign does not ask protocols to stop caring about their own digest format. A Permit2 digest can remain a Permit2 digest. An EIP-191 attestation can remain an EIP-191 attestation. A custom protocol hash can remain custom. Single Sign simply proves that each digest came from a piece of data the user already saw and signed. ## The Principle Users should not have to choose between safety and flow. Opaque batching can improve flow while making the signing moment harder to understand. Repeated signatures can preserve protocol-level clarity while making the product painful to use. Single Sign aims for the middle: - Sign the visible content once. - Prove individual digest inclusion later. - Let existing protocols verify the digest they already understand. One signature. Many requests. Fully visible authorization. Verifiable digest inclusion.