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.
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:
request_0 || request_1 || request_2 || ... || request_nEach 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.
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:
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:
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.
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:
- Verify the aggregate signature against the full
typed_data_concatbytes. - 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:
digest = keccak256("\x19\x01" || domainSeparator || hashStruct(message))The guest commits only:
(signer, digest)as public output.
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.
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:
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:
{
"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.
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:
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:
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.
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:
This is the main user experience improvement:
- The user sees one aggregate.
- The user signs once.
- Each protocol can still verify the exact digest it expects.
- 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.
Written by
Kai Aldag
I build and write about crypto, cryptography, and distributed systems. Egg Tech is my one-person company — the home for what I ship and the notes I keep along the way.