Skip to content

Commit dbb2463

Browse files
authored
docs: add dev docs (#53)
* docs: add dev docs * expand and fix typos * grammar and typos * link docs from readme * split and expand a bit * typo
1 parent 03f2cea commit dbb2463

File tree

10 files changed

+455
-5
lines changed

10 files changed

+455
-5
lines changed

README.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ Aiming to be compliant with the [ERC-7579](https://erc7579.com/) standard.
77

88
Based on the [ERC-7579 reference implementation](https://github.com/erc7579/erc7579-implementation).
99

10-
> [!CAUTION]
11-
> The factory and module interfaces are not yet stable! Any contracts interfacing
12-
> `ModularSmartAccount` will likely need to be updated in the
13-
> final version. The code is currently under audit and the latest may contain
14-
> security vulnerabilities.
10+
Developer documentation: [here](./docs/README.md).
1511

1612
## Local Development
1713

docs/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Developer Documentation
2+
3+
## Overview
4+
5+
ZKsync SSO is a modular smart account compliant with [ERC-4337](https://eips.ethereum.org/EIPS/eip-4337) and [ERC-7579](https://eips.ethereum.org/EIPS/eip-7579) and based on the [ERC-7579 reference implementation](https://github.com/erc7579/erc7579-implementation).
6+
7+
Being familiar with these standards can prove useful while reading this documentation.
8+
9+
## Features
10+
11+
- **Modular & Extendable Architecture**: Pluggable validators and executors following the ERC-7579 standard; supports existing 3rd-party modules
12+
- **Multiple Authentication Methods**: Support for EOA keys, WebAuthn passkeys, and session keys
13+
- **Session Key Support**: Grant third parties limited, time-bound access with fine-grained permissions
14+
- **Account Recovery**: Guardian-based recovery system for lost keys or passkeys
15+
- **Upgradeable**: Factory and modules are behind transparent proxies; accounts use beacon proxies
16+
17+
## Documentation
18+
19+
- [Architecture](./architecture.md) - System design and component relationships
20+
- [Deploying](./deploying.md) - Deployment instructions and scripts
21+
- [Modules](./modules.md) - Available modules and their APIs
22+
- EOAKeyValidator - EOA owner validation
23+
- WebAuthnValidator - Passkey/WebAuthn support
24+
- SessionKeyValidator - Session key management with usage limits
25+
- GuardianExecutor - Guardian-based account recovery
26+
- [Registry](./registry.md) - ERC-7484 module registry integration
27+
- [Calldata Format](./calldata-format.md) - ERC-7579 execution calldata encoding
28+
- [Signature Formats](./signature-formats.md) - Signature encoding for each validator

docs/architecture.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Architecture
2+
3+
![Architecture](./images/architecture.png)
4+
5+
The factory and all modules are behind `TransparentUpgradeableProxy`s.
6+
7+
Factory deploys `BeaconProxy`s that point to the `UpgradeableBeacon` which in turn has the account implementation address.
8+
9+
The following is a sequence diagram for the general SSO user flow:
10+
11+
![General flow](./images/general-flow.png)

docs/calldata-format.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# General ERC-7579 Calldata Format
2+
3+
There are 2 possible scenarios for a call from `EntryPoint` to the smart account:
4+
5+
1. Call to a **core account method**, e.g. `installModule`
6+
2. Call to an **external address**
7+
8+
Calls to **core account methods** are trivially formatted: method selector + ABI encoded parameters.
9+
10+
Calls to **external addresses** are made via `execute(ModeCode code, bytes calldata data)` method. `code` is a `bytes32` value created according to the [standard](https://eips.ethereum.org/EIPS/eip-7579#execution-behavior):
11+
12+
- `callType` (1 byte): `0x00` for a single `call`, `0x01` for a batch `call`, `0xfe` for `staticcall` and `0xff` for `delegatecall`
13+
- `execType` (1 byte): `0x00` for executions that revert on failure, `0x01` for executions that do not revert on failure but emit `event TryExecuteUnsuccessful(uint256 batchExecutionindex, bytes returnData)` on error
14+
- unused (4 bytes): this range is reserved for future standardization
15+
- `modeSelector` (4 bytes): an additional mode selector that can be used to create further execution modes, **currently unused**
16+
- `modePayload` (22 bytes): additional data to be passed, **currently unused**
17+
18+
Depending on the value of the `callType`, data is one of the following:
19+
20+
- if `callType == CALLTYPE_SINGLE`, `data` is `abi.encodePacked(target, value, callData)`
21+
- if `callType == CALLTYPE_DELEGATECALL`, `data` is `abi.encodePacked(target, callData)`
22+
- if `callType == CALLTYPE_BATCH`, `data` is `abi.encode(executions)` where `executions` is an array `Execution[]` and
23+
24+
```solidity
25+
struct Execution {
26+
address target;
27+
uint256 value;
28+
bytes callData;
29+
}
30+
```
31+
32+
## Example
33+
34+
An external call to the contract `Storage` method `setValue(uint256 value)` with parameter 42 would have calldata as follows:
35+
36+
```solidity
37+
abi.encodeCall(IERC7579Account.execute, (
38+
bytes32(0), // callType: single, execType: default
39+
abi.encodePacked(
40+
storageAddress, // target
41+
0, // value
42+
abi.encodeCall(Storage.setValue, (42)) // callData
43+
)
44+
)
45+
```

docs/deploying.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Deploying
2+
3+
## Using forge
4+
5+
To deploy the contracts, use the `Deploy.s.sol` script.
6+
7+
To deploy the factory and 4 modules (`EOAKeyValidator`, `SessionKeyValidator`, `WebAuthnValidator` and `GuardianExecutor`):
8+
9+
```bash
10+
forge script script/Deploy.s.sol \
11+
--rpc-url $RPC_URL \
12+
--private-key $DEPLOYER \
13+
--broadcast
14+
```
15+
16+
This should be used for if a new clean setup is desired (e.g. on a new network).
17+
This will not deploy any accounts yet.
18+
19+
---
20+
21+
To deploy an account from an existing factory with preinstalled modules:
22+
23+
```bash
24+
forge script script/Deploy.s.sol --sig 'deployAccount(address,address[])' $FACTORY_ADDRESS $MODULES_ADDRESSES \
25+
--rpc-url $RPC_URL \
26+
--private-key $DEPLOYER \
27+
--broadcast
28+
```
29+
30+
This should be used to deploy an account when a factory is already deployed on the network, and/or a custom set of preinstalled modules is desired.
31+
32+
---
33+
34+
To deploy everything at once (factory, all 4 default modules, and an account with all 4 default modules installed):
35+
36+
```bash
37+
forge script script/Deploy.s.sol --sig 'deployAll()' \
38+
--rpc-url $RPC_URL \
39+
--private-key $DEPLOYER \
40+
--broadcast
41+
```
42+
43+
This should primarily be used during testing.
44+
45+
---
46+
47+
In each case, admin of the factory and all modules will be the deployer.
48+
For the account, the deployer's key will be registered as an EOA owner in the `EOAKeyValidator`.
49+
50+
Address of the new account can be found in the emitted event `AccountCreated(address indexed newAccount, address indexed deployer)`.
51+
52+
## Manually
53+
54+
To deploy an account from an existing factory, call `deployAccount(bytes32 salt, bytes calldata initData)` on the factory. `initData` must be encoded in the following format:
55+
56+
```solidity
57+
address[] memory modules = ... // modules to be installed on the new account
58+
bytes[] memory data = ... // initialization data for each module (empty if not needed)
59+
initData = abi.encodeCall(IMSA.initializeAccount, (modules, data))
60+
```
61+
62+
Modules installed this way have to be of single type and must not repeat in the array.

docs/images/architecture.png

152 KB
Loading

docs/images/general-flow.png

200 KB
Loading

docs/modules.md

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# Modules
2+
3+
Currently, only validator, fallback and executor module types are supported (as defined in the standard).
4+
5+
To install, uninstall or unlink a module, call the corresponding core functions on the account contract:
6+
7+
- `installModule(uint256 typeId, address module, bytes calldata initData)`
8+
- `uninstallModule(uint256 typeId, address module, bytes calldata deinitData)`
9+
- `unlinkModule(uint256 typeId, address module, bytes calldata deinitData)`
10+
11+
`typeId`, according to the [standard](https://eips.ethereum.org/EIPS/eip-7579):
12+
- 1 for validator
13+
- 2 for executor
14+
- 3 for fallback
15+
16+
The account will call module's `onInstall(bytes)` hook upon installation and `onUninstall(bytes)` hook upon uninstall and unlink. The format for the supplied data is different for each module and is described below.
17+
18+
Unlinking is the same as uninstalling, but does not fail if `onUninstall` call to the module fails. Instead, error is emitted as `ModuleUnlinked(uint256 indexed typeId, address indexed module, bytes errorMsg)` event.
19+
20+
A single contract can house multiple module types, each type is installed separately.
21+
22+
## `EOAKeyValidator`
23+
24+
Stores EOA addresses as account owners. Each address has full "admin" privileges to the account, as long as this validator is installed.
25+
26+
- `onInstall` data format: ABI-encoded array of addresses - initial owners
27+
- `onUninstall` data format: ABI-encoded array of addresses - owners to remove
28+
29+
Other methods:
30+
- `addOwner(address owner)` - adds an EOA owner, emits `OwnerAdded(address indexed account, address indexed owner)`
31+
- `removeOwner(address owner)` - removes existing EOA owner, emits `OwnerRemoved(address indexed account, address indexed owner)`
32+
- `isOwnerOf(address account, address owner) returns (bool)` - whether or not an address is an owner of the account
33+
34+
## `WebAuthnValidator`
35+
36+
Stores WebAuthn passkeys per origin domain for each account. Each passkey has full "admin" privileges to the account, as long as this validator is installed.
37+
38+
- `onInstall` data format: ABI-encoded `(bytes credentialId, bytes32[2] publicKey, string domain)` - initial passkey, or empty
39+
- `onUninstall` data format: ABI-encoded array of `(string domain, bytes credentialId)` - passkeys to remove
40+
41+
Other methods:
42+
- `addValidationKey(bytes credentialId, bytes32[2] newKey, string domain)` - adds new passkey
43+
- `removeValidationKey(bytes credentialId, string domain)` - removes existing passkey
44+
- `getAccountKey(string domain, bytes credentialId, address account) returns (bytes32[2])` - account's public key on the domain with given credential ID
45+
- `getAccountList(string domain, bytes credentialId) returns (address[])` - list of accounts on the domain with given credential ID (normally length of 1)
46+
47+
## `SessionKeyValidator`
48+
49+
Grants a 3rd party limited access to the account with configured permissions.
50+
51+
A session is defined by the following structure:
52+
53+
```solidity
54+
struct SessionSpec {
55+
address signer;
56+
uint48 expiresAt;
57+
UsageLimit feeLimit;
58+
CallSpec[] callPolicies;
59+
TransferSpec[] transferPolicies;
60+
}
61+
```
62+
63+
- `signer` - Address corresponding to an EOA private key that will be used to sign session transactions. **Signers are required to be globally unique.**
64+
- `expiresAt` - Timestamp after which the session no longer can be used. **Session expiration is required to be no earlier than 60 seconds after session creation.**
65+
- `feeLimit` - a `UsageLimit` (explained below) structure that limits how much fees this session can spend. **Required to not be `Unlimited`.**
66+
- `callPolicies` - a `CallSpec` (explained below) array that defines what kinds of calls are permitted in the session. **The array has to have unique (`target`, `selector`) pairs.**
67+
- `transferPolicies` - a `TransferSpec` (explained below) array that defines what kinds of transfers (calls with no calldata) are permitted in the session. **The array has to have unique targets.**
68+
69+
### Usage Limits
70+
71+
All usage limits are defined by the following structure:
72+
73+
```solidity
74+
struct UsageLimit {
75+
LimitType limitType; // can be Unlimited (0), Lifetime (1) or Allowance (2)
76+
uint256 limit; // ignored if limitType == Unlimited
77+
uint48 period; // ignored if limitType != Allowance
78+
}
79+
```
80+
81+
- `limitType` defines what kind of limit (if any) this is.
82+
- `Unlimited` does not define any limits.
83+
- `Lifetime` defines a cumulative lifetime limit: sum of all uses of the value in the current session has to not surpass `limit`.
84+
- `Allowance` defines a periodically refreshing limit: sum of all uses of the value during the current `period` has to not surpass `limit`.
85+
- `limit` - the actual number to limit by.
86+
- `period` - length of the period in seconds.
87+
88+
### Transfer Policies
89+
90+
Transfer policies are defined by the following structure:
91+
92+
```solidity
93+
struct TransferSpec {
94+
address target;
95+
uint256 maxValuePerUse;
96+
UsageLimit valueLimit;
97+
}
98+
```
99+
100+
- `target` - address to which transfer is being made.
101+
- `maxValuePerUse` - maximum value that is possible to send in one transfer.
102+
- `valueLimit` - cumulative transfer value limit.
103+
104+
### Call Policies
105+
106+
Call policies are defined by the following structure:
107+
108+
```solidity
109+
struct CallSpec {
110+
address target;
111+
bytes4 selector;
112+
uint256 maxValuePerUse;
113+
UsageLimit valueLimit;
114+
Constraint[] constraints;
115+
}
116+
```
117+
118+
- `target` - address to which call is being made.
119+
- `selector` - selector of the method being called.
120+
- `maxValuePerUse` - maximum value that is possible to send in one call.
121+
- `valueLimit` - cumulative call value limit.
122+
- `constraints` - array of `Constraint` (explained below) structures that define constraints on method arguments.
123+
124+
### Call Constraints
125+
126+
Call constraints are defined by the following structures:
127+
128+
```solidity
129+
struct Constraint {
130+
Condition condition;
131+
uint64 index;
132+
bytes32 refValue;
133+
UsageLimit limit;
134+
}
135+
136+
137+
enum Condition {
138+
Unconstrained,
139+
Equal,
140+
Greater,
141+
Less,
142+
GreaterOrEqual,
143+
LessOrEqual,
144+
NotEqual
145+
}
146+
```
147+
148+
- `index` - index of the argument in the called method, starting with 0, assuming all arguments are 32-byte words after ABI-encoding.
149+
- E.g., specifying `index: X` will constrain calldata bytes `4+32*X:4+32*(X+1)`
150+
- `limit` - usage limit for the argument interpreted as `uint256`.
151+
- `condition` - how the argument is required to relate to `refValue`: see `enum Condition` above.
152+
- `refValue` - reference value for the condition; ignored if condition is `Unconstrained`.
153+
154+
### `SessionKeyValidator` Methods
155+
156+
- `onInstall` data format: empty
157+
- `onUninstall` data format: ABI-encoded array of session hashes to revoke
158+
159+
Other methods:
160+
- `createSession(SessionSpec spec, bytes proof)` - create a new session; requires `proof` - a signature of the hash `keccak256(abi.encode(sessionHash, accountAddress))` signed by session `signer`
161+
- `revokeKey(bytes32 sessionHash)` - closes an active session by the provided hash
162+
- `revokeKeys(bytes32[] sessionHashes)` - same as `revokeKey` but closes multiple sessions at once
163+
- `sessionStatus(address account, bytes32 sessionHash) returns (SessionStatus)` - returns `NotInitialized` (0), `Active` (1) or `Closed` (2); note: expired sessions are still considered active if not revoked explicitly
164+
- `sessionState(address account, SessionSpec spec) returns (SessionState)` - returns the session status and the state of all cumulative limits used in the session as a following structure:
165+
166+
```solidity
167+
// Info about remaining session limits and its status
168+
struct SessionState {
169+
Status status;
170+
uint256 feesRemaining;
171+
LimitState[] transferValue;
172+
LimitState[] callValue;
173+
LimitState[] callParams;
174+
}
175+
176+
struct LimitState {
177+
uint256 remaining; // this might also be limited by a constraint or `maxValuePerUse`, which is not reflected here
178+
address target;
179+
bytes4 selector; // ignored for transfer value
180+
uint256 index; // ignored for transfer and call value
181+
}
182+
```
183+
184+
Note: `sessionHash` is what is stored on-chain, and is defined by `keccak256(abi.encode(sessionSpec))`.
185+
186+
## `GuardianExecutor`
187+
188+
Stores addresses trusted by the account to perform an EOA or WebAuthn key recovery. Either `EOAKeyValidator` or `WebAuthnValidator` must be installed.
189+
190+
The flow is the following:
191+
192+
```mermaid
193+
sequenceDiagram
194+
actor Guardian
195+
participant GuardiansExecutor
196+
participant SmartAccount as SmartAccount (ERC-7579)
197+
participant WebauthnValidator
198+
actor User
199+
200+
User->>SmartAccount: proposeGuardian(guardian)
201+
SmartAccount-->>WebauthnValidator: validate
202+
WebauthnValidator-->>SmartAccount: ok
203+
SmartAccount->>GuardiansExecutor: proposeGuardian(guardian)
204+
Guardian->>GuardiansExecutor: acceptGuardian(account)
205+
206+
Note over User: Lose passkey
207+
208+
Guardian->>GuardiansExecutor: initializeRecovery(account, new passkey)
209+
Note over Guardian: Wait 24 hours
210+
Guardian->>GuardiansExecutor: finalizeRecovery(account, new passkey)
211+
GuardiansExecutor->>SmartAccount: executeFromExecutor("add new passkey")
212+
SmartAccount->>WebauthnValidator: addValidationKey(new passkey)
213+
```
214+
215+
Important notes:
216+
- Account owner has to first propose to another address to be its guardian
217+
- After the guardian address accepts, it can initiate a recovery
218+
- Recovery can be either for an EOA key or a Webauthn passkey, given that a corresponding validator is installed on the account
219+
- Any guardian can initiate a recovery alone. Guardians can themselves be multisig accounts if that is desired
220+
- A user can discard an initiated recovery in case one of the guardians is malicious
221+
- Recovery can be finalized not earlier than 24 hours and not later than 72 hours after initiating it
222+
- Only one recovery can be ongoing at a time
223+
224+
### `GuardianExecutor` Methods
225+
226+
- `onInstall` data format: empty
227+
- `onUninstall` data format: empty
228+
229+
Other methods:
230+
- `proposeGuardian(address newGuardian)` - propose an address to be a guardian
231+
- `acceptGuardian(address accountToGuard)` - an address that was proposed to can accept its role as a guardian
232+
- `initializeRecovery(address accountToRecover, RecoveryType recoveryType, bytes data)` - initialize recovery of an EOA key (`recoveryType` 1) or passkey (`recoveryType` 2) of an account; `data` is ABI-encoded arguments to `EOAKeyValidator.addOwner` or `WebAuthnValidator.addValidationKey`
233+
- `finalizeRecovery(address account, bytes data)` - finalize an ongoing recovery; the same data has to be passed in as was passed during initializing
234+
- `discardRecovery()` - discard an ongoing recovery
235+
- `guardianStatusFor(address account, address guardian) returns (bool isPresent, bool isActive)` - whether a given address was proposed to (is present) and has accepted (is active)

0 commit comments

Comments
 (0)