11---
22title : " Access Control"
33sidebarTitle : " Access Control"
4- description : " Restrict function access to authorized accounts ."
4+ description : " Restrict circuit access using witness-derived keypairs ."
55" og:title " : " Access Control - Compact by Example"
6- " og:description " : " Owner and role-based access control patterns for smart contracts"
6+ " og:description " : " Witness-derived keypair authorization for Compact smart contracts"
77" twitter:card " : " summary_large_image"
88" twitter:title " : " Access Control - Compact by Example"
9- " twitter:description " : " Owner and role-based access control patterns for smart contracts"
10- ---le: "Access Control"
11- sidebarTitle : " Access Control"
12- description : " Restrict function access to authorized accounts."
13- " og:title " : " Access Control - Compact by Example"
14- " og:description " : " Owner-only and role-based access control patterns"
9+ " twitter:description " : " Witness-derived keypair authorization for Compact smart contracts"
1510---
1611
12+ <Warning >
13+ ** Security warning — ` ownPublicKey() ` does not authorize the caller.**
14+
15+ ` ownPublicKey() ` returns a value the prover knows, not a proof of key
16+ ownership. Any value stored on the public ledger can be replayed by anyone
17+ reading the chain. Do not use ` ownPublicKey() ` for authorization checks
18+ (` assert(ownPublicKey() == storedAdmin) ` ). Use it only as an identifier for
19+ the operation's target (e.g. "mint to this caller"). For authorization, see
20+ [ Owner-Only Pattern] ( #owner-only-pattern ) below.
21+ </Warning >
22+
1723## The Pattern
1824
1925<Accordion title = " View Code" >
2026
2127``` compact
22- pragma language_version >= 0.17 .0;
28+ pragma language_version >= 0.22 .0;
2329
2430import CompactStandardLibrary;
2531
26- // Store contract owner
27- export ledger owner: Either<ZswapCoinPublicKey, ContractAddress>;
32+ struct AdminSecretKey { bytes: Bytes<32>; }
33+ struct AdminPublicKey { bytes: Bytes<32>; }
34+
35+ // The ledger stores only the derived public key (a hash of the secret),
36+ // never the secret itself.
37+ export ledger contractAdmin: AdminPublicKey;
2838
29- // Initialize owner in constructor
30- circuit constructor(): [] {
31- owner = disclose(left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()));
39+ // The deployer's DApp generates this secret and keeps it in private state.
40+ witness getAdminSecret(): AdminSecretKey;
41+
42+ constructor() {
43+ contractAdmin = disclose(deriveAdminPublicKey(getAdminSecret()));
3244}
3345
34- // Owner-only function
35- export circuit restrictedFunction(): [] {
36- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
37- assert(caller == owner, "Only owner can call");
46+ export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
47+ return AdminPublicKey {
48+ bytes: persistentHash<Vector<2, Bytes<32>>>([
49+ pad(32, "myapp:admin:pk:v1"),
50+ sk.bytes
51+ ])
52+ };
53+ }
3854
39- // Protected logic here
55+ export circuit privilegedAction(): [] {
56+ // To pass, the caller must supply a witness `sk` such that
57+ // H("myapp:admin:pk:v1" || sk) == contractAdmin.
58+ assert(
59+ contractAdmin == deriveAdminPublicKey(getAdminSecret()),
60+ "Not authorized."
61+ );
62+ // ... privileged logic
4063}
4164
42- // Get caller address
43- export circuit getCaller(): Either<ZswapCoinPublicKey, ContractAddress> {
44- return left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
65+ // Rotate the admin key without ever transmitting a private key.
66+ // The new admin generates their secret locally and shares only the
67+ // derived public key with the current admin.
68+ export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
69+ assert(
70+ contractAdmin == deriveAdminPublicKey(getAdminSecret()),
71+ "Not authorized to rotate admin."
72+ );
73+ contractAdmin = disclose(newAdmin);
4574}
4675```
4776
4877</Accordion >
4978
5079## Owner-Only Pattern
5180
52- ### Set Owner in Constructor
81+ The contract stores ` H(domain || secret) ` on the ledger, never the secret
82+ itself. The admin's DApp holds the secret in private state and re-derives
83+ the public key on every call. The on-chain value gives an attacker only the
84+ hash; hash preimage resistance means they cannot forge a matching secret.
85+
86+ ### Store the Admin Public Key
5387
5488``` compact
55- export ledger owner: Either<ZswapCoinPublicKey, ContractAddress>;
89+ struct AdminSecretKey { bytes: Bytes<32>; }
90+ struct AdminPublicKey { bytes: Bytes<32>; }
91+
92+ export ledger contractAdmin: AdminPublicKey;
5693
57- circuit constructor(): [] {
58- owner = disclose(left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()));
94+ witness getAdminSecret(): AdminSecretKey;
95+
96+ constructor() {
97+ contractAdmin = disclose(deriveAdminPublicKey(getAdminSecret()));
98+ }
99+
100+ export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
101+ return AdminPublicKey {
102+ bytes: persistentHash<Vector<2, Bytes<32>>>([
103+ pad(32, "myapp:admin:pk:v1"),
104+ sk.bytes
105+ ])
106+ };
59107}
60108```
61109
62- The deployer automatically becomes the owner.
110+ The domain string ` "myapp:admin:pk:v1" ` binds the public key to this
111+ contract. The same secret will not derive the same public key under a
112+ different domain, so an admin key cannot be replayed across contracts.
63113
64- ### Restrict Function Access
114+ ### Authorization Check
65115
66116``` compact
67- export circuit mint(
68- account: Either<ZswapCoinPublicKey, ContractAddress>,
69- amount: Uint<128>
70- ): [] {
71- // Only owner can mint
72- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
73- assert(caller == owner, "Only owner can mint");
74-
75- _mint(account, amount);
117+ export circuit privilegedAction(): [] {
118+ assert(
119+ contractAdmin == deriveAdminPublicKey(getAdminSecret()),
120+ "Not authorized."
121+ );
122+ // ... privileged logic
76123}
77124```
78125
79- Check caller matches owner before executing.
126+ The assertion succeeds only if the caller knows a secret whose hash matches
127+ the stored value. Reading ` contractAdmin ` off-chain gives an attacker the
128+ hash, not the preimage.
80129
81- ### Transfer Ownership
130+ ### Rotate the Admin
82131
83132``` compact
84- export circuit transferOwnership(
85- newOwner: Either<ZswapCoinPublicKey, ContractAddress>
86- ): [] {
87- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
88- assert(caller == owner, "Only owner can transfer");
89-
90- owner = disclose(newOwner);
133+ export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
134+ assert(
135+ contractAdmin == deriveAdminPublicKey(getAdminSecret()),
136+ "Not authorized to rotate admin."
137+ );
138+ contractAdmin = disclose(newAdmin);
91139}
92140```
93141
94- Allow current owner to transfer to new owner.
142+ The new admin generates their secret locally and shares only the derived
143+ public key with the current admin. Private keys never leave the holder's
144+ DApp.
95145
96- ## Caller Identification
146+ ## Why This Works
97147
98- ### Get Caller Address
148+ The ledger stores ` H(domain || secret) ` — the hash of the admin's secret.
149+ To pass the assertion, the caller must provide a witness ` sk ` such that
150+ ` H(domain || sk) ` equals the stored value. The on-chain value is a hash,
151+ not the secret itself, so reading the ledger gives an attacker nothing they
152+ can replay. The domain string prevents the same secret from being valid
153+ across different contracts.
99154
100- ``` compact
101- // In any circuit
102- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
103- ```
155+ This is what Solidity gets implicitly from ` msg.sender ` and EVM signature
156+ verification. In Compact, you construct the check yourself.
104157
105- ` ownPublicKey() ` returns the public key of whoever called the circuit.
158+ ## Anti-Pattern: ownPublicKey() as Authorization
106159
107- ### Authorize Sender
160+ <Warning >
161+ ** Do not use this pattern.** It is shown only to document what fails.
162+ </Warning >
108163
109164``` compact
110- export circuit transfer(
111- to: Either< ZswapCoinPublicKey, ContractAddress>,
112- amount: Uint<128>
113- ): [] {
114- // Sender is always the caller
115- const from = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
116-
117- // Transfer from caller's balance
118- _transfer(from, to, amount );
165+ // DO NOT USE — this is broken
166+ export ledger admin: ZswapCoinPublicKey;
167+
168+ constructor() {
169+ admin = disclose(ownPublicKey());
170+ }
171+
172+ export circuit privileged(): [] {
173+ assert(ownPublicKey() == admin, "Only admin." );
119174}
120175```
121176
122- The caller is automatically authorized to spend their own tokens.
177+ ` ownPublicKey() ` returns whatever value the prover claims to know. An
178+ attacker reads ` admin ` from the public ledger, supplies it as their own
179+ ` ownPublicKey() ` , and produces a mathematically valid proof. The proof
180+ correctly demonstrates "the prover knows a value equal to ` admin ` " — which
181+ anyone watching the chain does. The check provides no authorization.
123182
124- ## Role-Based Access
183+ ## When to Use ownPublicKey()
125184
126- ### Multiple Roles
185+ ` ownPublicKey() ` is the correct primitive when you need a stable identifier
186+ for the caller as the ** target** of an operation:
127187
128- ``` compact
129- export ledger owner: Either<ZswapCoinPublicKey, ContractAddress>;
130- export ledger minter: Either<ZswapCoinPublicKey, ContractAddress>;
131- export ledger burner: Either<ZswapCoinPublicKey, ContractAddress>;
132-
133- // Owner can mint and burn
134- export circuit mint(account: Either<ZswapCoinPublicKey, ContractAddress>, amount: Uint<128>): [] {
135- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
136- assert(caller == owner || caller == minter, "Not authorized to mint");
137- _mint(account, amount);
138- }
188+ - "Mint a token to this caller": ` mint(ownPublicKey(), tokenId) `
189+ - "Credit this caller's balance": ` balances.insert(ownPublicKey(), ...) `
190+ - "Record this caller as the recipient": ` recipient = disclose(ownPublicKey()) `
139191
140- export circuit burn(amount: Uint<128>): [] {
141- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
142- assert(caller == owner || caller == burner, "Not authorized to burn");
143- _burn(caller, amount);
144- }
145- ```
192+ It identifies who the operation is for. It does not prove who is allowed
193+ to perform it. The moment you write ` assert(ownPublicKey() == ...) ` , you
194+ have crossed into authorization, and the pattern breaks.
146195
147- Different roles for different operations.
196+ ## Role-Based Access
148197
149- ### Admin Functions
198+ Multiple roles use the same keypair pattern with one ledger slot per role
199+ and a single witness that returns the caller's secret.
150200
151201``` compact
152- export circuit setMinter(newMinter: Either<ZswapCoinPublicKey, ContractAddress>): [] {
153- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
154- assert(caller == owner, "Only owner");
202+ struct SecretKey { bytes: Bytes<32>; }
203+ struct PublicKey { bytes: Bytes<32>; }
204+
205+ export ledger contractOwner: PublicKey;
206+ export ledger contractMinter: PublicKey;
155207
156- minter = disclose(newMinter);
208+ witness getCallerSecret(): SecretKey;
209+
210+ constructor(initialMinter: PublicKey) {
211+ contractOwner = disclose(derivePublicKey(getCallerSecret()));
212+ contractMinter = disclose(initialMinter);
213+ }
214+
215+ export circuit derivePublicKey(sk: SecretKey): PublicKey {
216+ return PublicKey {
217+ bytes: persistentHash<Vector<2, Bytes<32>>>([
218+ pad(32, "myapp:role:v1"),
219+ sk.bytes
220+ ])
221+ };
157222}
158223
159- export circuit setBurner(newBurner: Either<ZswapCoinPublicKey, ContractAddress>): [] {
160- const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
161- assert(caller == owner, "Only owner");
224+ // Owner-only: rotate the minter slot
225+ export circuit setMinter(newMinter: PublicKey): [] {
226+ assert(
227+ contractOwner == derivePublicKey(getCallerSecret()),
228+ "Only owner can set minter."
229+ );
230+ contractMinter = disclose(newMinter);
231+ }
162232
163- burner = disclose(newBurner);
233+ // Either role may call this
234+ export circuit mintingAction(): [] {
235+ const callerKey = disclose(derivePublicKey(getCallerSecret()));
236+ assert(
237+ callerKey == contractOwner || callerKey == contractMinter,
238+ "Not authorized to mint."
239+ );
240+ // ... minting logic
164241}
165242```
166243
167- Owner manages role assignments.
244+ The ` || ` check would reveal which role matched, so ` callerKey ` is disclosed
245+ explicitly. That is safe: a derived public key equals the on-chain value
246+ only when the caller is already authorized, and the on-chain value is
247+ public anyway.
248+
249+ ## Alternative Patterns
250+
251+ The keypair pattern above covers single-admin and role-based authorization.
252+ For other situations:
253+
254+ - ** Signature verification against a stored verifying key** — when you need
255+ multi-sig, cold-key signing, or cross-contract authority. The contract
256+ stores a verifying key; privileged circuits accept a signature over the
257+ operation as a parameter.
258+ - ** Commitment / nullifier patterns** — when you need anonymous user
259+ authorization with unlinkability across actions (anonymous voting,
260+ single-use credentials). The contract stores commitments and tracks
261+ nullifiers.
262+
263+ Both are documented elsewhere.
168264
169265## What's Next
170266
@@ -173,6 +269,6 @@ Owner manages role assignments.
173269 Apply access control to minting
174270 </Card >
175271 <Card title = " ERC20 Token" icon = " coins" href = " /applications/erc20-token" >
176- See access control in complete token
272+ See access control in a complete token
177273 </Card >
178274</CardGroup >
0 commit comments