# 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<br/>EIP-712 Permit2"] --> D["Visible aggregate message"]
    B["Raw sign request 1<br/>EIP-712 order"] --> D
    C["Raw sign request 2<br/>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<br/>typed-data JSON"]
        R1["request_1<br/>typed-data JSON"]
        R2["request_2<br/>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<br/>[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<br/>Compute EIP-712 hash"]
    Kind -->|"EIP-191"| E191["Apply personal-message prefix<br/>Compute message hash"]
    Kind -->|"Raw32"| Raw["Use 32-byte digest directly"]
    Kind -->|"Custom"| Custom["Run app-specific digest rules"]
    E712 --> Out["Public journal<br/>(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<br/>isValidSignature(digest, proof)"] --> S["SingleSign contract"]
    S --> J["Build journal<br/>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<br/>prepare sign requests"] --> Normalize["Normalize and compact<br/>deterministic bytes"]
    Normalize --> Aggregate["Concatenate into one<br/>visible aggregate"]
    Aggregate --> Wallet["Wallet displays aggregate<br/>user signs once"]
    Wallet --> Sig["Aggregate EIP-191 signature"]

    Aggregate --> Ranges["Compute byte ranges<br/>for each request"]
    Sig --> Prover["Prover"]
    Ranges --> Prover
    Prover --> Guest["zkVM guest verifies signature<br/>and recomputes selected digest"]
    Guest --> Receipt["Receipt / proof<br/>journal: (signer, digest)"]
    Receipt --> Contract["ERC-1271 SingleSign contract"]
    Contract --> Protocol["Protocol accepts digest<br/>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.
