Skip to content

Commit e24f305

Browse files
feat(token): add native shielded token standard MIP-0011 (OpenZeppelin#621)
Signed-off-by: 0xisk <0xisk@proton.me> Co-authored-by: Andrew Fleming <fleming-andrew@protonmail.com>
1 parent 30b5365 commit e24f305

12 files changed

Lines changed: 2312 additions & 0 deletions
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
// SPDX-License-Identifier: MIT
2+
// OpenZeppelin Compact Contracts v0.2.0 (token/NativeShieldedToken.compact)
3+
4+
pragma language_version >= 0.23.0;
5+
6+
/**
7+
* @module NativeShieldedToken
8+
* @description A module for issuing a SINGLE native shielded (Zswap) token —
9+
* the ERC-20-shaped flavor of the native shielded token standard. The token's
10+
* domain separator is fixed at construction; no circuit takes a domain
11+
* parameter. To issue multiple token types from one contract, use
12+
* `NativeShieldedTokenFamily` instead.
13+
*
14+
* This module is a thin wrapper over `NativeShieldedTokenCore`: it stores the
15+
* one `sealed _domain` and forwards it to every core op, so the mint/burn logic
16+
* and metadata live in the shared core, not here.
17+
*
18+
* @notice A contract MUST NOT import both `NativeShieldedToken` and
19+
* `NativeShieldedTokenFamily`. Both wrap `NativeShieldedTokenCore`, so they
20+
* share one set of core state (a single init flag and one name/symbol/decimals).
21+
* Pick one flavor per contract.
22+
*
23+
* @notice This module exposes composable building blocks for minting and
24+
* burning native shielded coins. It does NOT include access control: consuming
25+
* contracts compose that via the module/contract pattern (Ownable,
26+
* AccessControl, etc.) and SHOULD gate the mint and burn circuits, which are
27+
* unrestricted at the module level.
28+
*
29+
* @dev The domain separator is stored as `sealed ledger _domain` at
30+
* construction and never exposed as a circuit parameter, eliminating
31+
* caller-supplied domain misuse. The coin's `color` is
32+
* `tokenType(_domain, kernel.self())`, computed at call time; only this
33+
* contract can mint coins of its color. Metadata follows the FungibleToken
34+
* (ERC-20) convention (contract-wide `name`, `symbol`, `decimals`, fixed at
35+
* construction). Mint amounts are `Uint<64>`, burn amounts `Uint<128>` — an
36+
* asymmetry imposed by the protocol primitives, not a module choice. The
37+
* mint/burn mechanics and the `_burn` vs `_burnFromSelf` spend paths live in
38+
* `NativeShieldedTokenCore`.
39+
*
40+
* @notice With a secret, cryptographically random `_mint` nonce the mint is
41+
* recipient-private; the caller is responsible for nonce uniqueness (a reused
42+
* nonce produces a duplicate commitment, which the ledger rejects). For mints
43+
* that need no caller-managed nonce, compose `NativeShieldedTokenDerivedNonce`
44+
* and pass its `_deriveNonce()` output, at the cost of making those mints
45+
* recipient-public.
46+
*
47+
* @notice Supply accounting is opt-in: this module tracks NO supply totals.
48+
* Compose the optional `NativeShieldedTokenSupply` extension only if you need an
49+
* on-chain `totalSupply()`; see it for the privacy trade-off it introduces (it
50+
* makes contract-mediated burn amounts public).
51+
*
52+
* @dev Composition
53+
* Dual-representation tokens (shielded + unshielded with conversion) MUST build
54+
* on the token-family modules and the `NativeTokenConverter` extension, not on
55+
* this module: it stores a single load-bearing sealed `_domain` (one token
56+
* type), while the converter composes both bases' Family profiles.
57+
* (Initialization is tracked per-module inline, so the former
58+
* shared-`Initializable`-flag "one `initialize` per contract" limit no longer
59+
* applies.)
60+
*
61+
* @notice Out of scope (phase two, pending contract-to-contract support):
62+
* `balanceOf`, `allowance`, and transfer mediation are not representable for
63+
* native shielded UTXOs — once a user holds a coin, the contract cannot observe
64+
* or restrict its movement.
65+
*/
66+
module NativeShieldedToken {
67+
import CompactStandardLibrary;
68+
import "./NativeShieldedTokenCore" prefix Core_;
69+
70+
// Circuits use the `Core_` prefix above. The named import/re-export below is
71+
// only for the core state variables, so the implementing contract's ledger
72+
// keys read as `_name` etc. rather than `NativeShieldedTokenCore__name`. The
73+
// circuit names are intentionally NOT imported unprefixed, to avoid potential clashes.
74+
import { _name, _symbol, _decimals, _isInitialized } from "./NativeShieldedTokenCore";
75+
export { _name, _symbol, _decimals, _isInitialized };
76+
77+
/**
78+
* @description Domain separator fixed at construction; with the contract
79+
* address it determines this token's color.
80+
*/
81+
export sealed ledger _domain: Bytes<32>;
82+
83+
/**
84+
* @description Initializes the module's domain and metadata.
85+
* @dev This MUST be called in the implementing contract's constructor, and it
86+
* MUST be the only token operation performed there. Failure to call it can
87+
* lead to an irreparable contract; perform mint, burn, and `tokenColor` only
88+
* in post-deployment circuits, never in the constructor.
89+
*
90+
* @param {Bytes<32>} domainSep - Domain separator for this token's color.
91+
* @param {Opaque<"string">} name_ - The name of the token.
92+
* @param {Opaque<"string">} symbol_ - The symbol of the token.
93+
* @param {Uint<8>} decimals_ - The number of decimals used to get the user representation.
94+
* @return {[]} - Empty tuple.
95+
*/
96+
export circuit initialize(
97+
domainSep: Bytes<32>,
98+
name_: Opaque<"string">,
99+
symbol_: Opaque<"string">,
100+
decimals_: Uint<8>
101+
): [] {
102+
Core_initialize(name_, symbol_, decimals_);
103+
_domain = disclose(domainSep);
104+
}
105+
106+
/**
107+
* @description Returns whether the contract has been initialized.
108+
*
109+
* @circuitInfo k=6, rows=26
110+
*
111+
* @return {Boolean} - True once `initialize` has run.
112+
*/
113+
export circuit isInitialized(): Boolean {
114+
return Core_isInitialized();
115+
}
116+
117+
/**
118+
* @description Returns the token name.
119+
*
120+
* @circuitInfo k=6, rows=28
121+
*
122+
* Requirements:
123+
*
124+
* - Contract is initialized.
125+
*
126+
* @return {Opaque<"string">} - The token name.
127+
*/
128+
export circuit name(): Opaque<"string"> {
129+
return Core_name();
130+
}
131+
132+
/**
133+
* @description Returns the symbol of the token.
134+
*
135+
* @circuitInfo k=6, rows=28
136+
*
137+
* Requirements:
138+
*
139+
* - Contract is initialized.
140+
*
141+
* @return {Opaque<"string">} - The token symbol.
142+
*/
143+
export circuit symbol(): Opaque<"string"> {
144+
return Core_symbol();
145+
}
146+
147+
/**
148+
* @description Returns the number of decimals used to get its user representation.
149+
*
150+
* @circuitInfo k=6, rows=28
151+
*
152+
* Requirements:
153+
*
154+
* - Contract is initialized.
155+
*
156+
* @return {Uint<8>} - The decimals value.
157+
*/
158+
export circuit decimals(): Uint<8> {
159+
return Core_decimals();
160+
}
161+
162+
/**
163+
* @description Returns this token's coin color:
164+
* `tokenType(_domain, kernel.self())`, computed at call time.
165+
*
166+
* @circuitInfo k=13, rows=3971
167+
*
168+
* Requirements:
169+
*
170+
* - Contract is initialized.
171+
*
172+
* @return {Bytes<32>} - The coin color.
173+
*/
174+
export circuit tokenColor(): Bytes<32> {
175+
return Core_tokenColor(_domain);
176+
}
177+
178+
/**
179+
* @description Mints `amount` of the token to `recipient`, using a
180+
* caller-supplied nonce.
181+
*
182+
* @dev The caller is fully responsible for nonce uniqueness. Reusing a
183+
* nonce for the same (value, recipient) produces a duplicate commitment,
184+
* which the protocol rejects. With a secret random nonce this is the
185+
* recipient-private mint. For mints that require no caller-managed nonce,
186+
* compose the `NativeShieldedTokenDerivedNonce` extension and pass its
187+
* `_deriveNonce()` output.
188+
*
189+
* @notice The returned coin info is the only copy available to the
190+
* recipient; callers SHOULD deliver it out of band. Wallets cannot detect
191+
* contract-minted coins by scanning the chain.
192+
*
193+
* @circuitInfo k=14, rows=10843
194+
*
195+
* Requirements:
196+
*
197+
* - Contract is initialized.
198+
* - `recipient` is not zero.
199+
*
200+
* @param {Either<ZswapCoinPublicKey, ContractAddress>} recipient - The coin recipient.
201+
* @param {Uint<64>} amount - Quantity to mint. Capped at `Uint<64>` by the protocol.
202+
* @param {Bytes<32>} nonce - Caller-supplied nonce. Must be unique for this
203+
* contract's color.
204+
* @return {ShieldedCoinInfo} - The newly created coin's info (nonce, color, value).
205+
*/
206+
export circuit _mint(
207+
recipient: Either<ZswapCoinPublicKey, ContractAddress>,
208+
amount: Uint<64>,
209+
nonce: Bytes<32>
210+
): ShieldedCoinInfo {
211+
return Core__mint(_domain, recipient, amount, nonce);
212+
}
213+
214+
/**
215+
* @description Burns `amount` from `coin`, a coin provided within the
216+
* current transaction (e.g. paid in by the caller's wallet), and routes the
217+
* remaining change to `refundTo`.
218+
*
219+
* To destroy `coin` in full, pass `coin.value` as `amount`; the change
220+
* branch will not fire and `none` is returned. The `refundTo` value is
221+
* inert in that case but must still be supplied and non-zero.
222+
*
223+
* @dev The coin is received by the contract and spent in the same
224+
* transaction, so the spend goes through `sendImmediateShielded` (the
225+
* transient path). For coins the contract already holds, use
226+
* `_burnFromSelf` instead.
227+
*
228+
* @notice The returned refund coin info is the only copy available to
229+
* `refundTo`; callers SHOULD deliver it out of band. Wallets cannot detect
230+
* contract-sent coins by scanning the chain.
231+
*
232+
* @circuitInfo k=16, rows=47656
233+
*
234+
* Requirements:
235+
*
236+
* - Contract is initialized.
237+
* - `coin.color` is this contract's token color.
238+
* - `amount` is less than or equal to `coin.value`.
239+
* - `refundTo` is not zero (the zero key is the burn address; a zero
240+
* `refundTo` would silently burn the change as well).
241+
*
242+
* @param {ShieldedCoinInfo} coin - The coin to burn from.
243+
* @param {Uint<128>} amount - Value to destroy. Must be <= `coin.value`.
244+
* @param {Either<ZswapCoinPublicKey, ContractAddress>} refundTo - Where to
245+
* route the unspent portion (`coin.value - amount`).
246+
* @return {Maybe<ShieldedCoinInfo>} - The refund coin created for `refundTo`,
247+
* or `none` if the coin was burned in full.
248+
*/
249+
export circuit _burn(
250+
coin: ShieldedCoinInfo,
251+
amount: Uint<128>,
252+
refundTo: Either<ZswapCoinPublicKey, ContractAddress>
253+
): Maybe<ShieldedCoinInfo> {
254+
return Core__burn(_domain, coin, amount, refundTo);
255+
}
256+
257+
/**
258+
* @description Burns `amount` from `coin`, a coin this contract already
259+
* holds (a Merkle-tree entry with a valid `mt_index`). Any change is
260+
* auto-received by the contract and returned.
261+
*
262+
* @dev The consumer SHOULD persist the returned change coin info in its own
263+
* ledger state: the change replaces `coin` as the contract's holding, and
264+
* its info is not otherwise recoverable.
265+
*
266+
* @circuitInfo k=15, rows=23307
267+
*
268+
* Requirements:
269+
*
270+
* - Contract is initialized.
271+
* - `coin.color` is this contract's token color.
272+
* - `amount` is less than or equal to `coin.value`.
273+
*
274+
* @param {QualifiedShieldedCoinInfo} coin - The contract-held coin to burn from.
275+
* @param {Uint<128>} amount - Value to destroy. Must be <= `coin.value`.
276+
* @return {Maybe<ShieldedCoinInfo>} - The change coin retained by the
277+
* contract, or `none` if the coin was burned in full.
278+
*/
279+
export circuit _burnFromSelf(
280+
coin: QualifiedShieldedCoinInfo,
281+
amount: Uint<128>
282+
): Maybe<ShieldedCoinInfo> {
283+
return Core__burnFromSelf(_domain, coin, amount);
284+
}
285+
}

0 commit comments

Comments
 (0)