Repository navigation
Expand file tree
/
Copy pathMASP.sol
More file actions
1142 lines (1061 loc) · 50.8 KB
/
Copy pathMASP.sol
File metadata and controls
1142 lines (1061 loc) · 50.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import { ISignatureTransfer } from "permit2/src/interfaces/ISignatureTransfer.sol";
import { IAllowanceTransfer } from "permit2/src/interfaces/IAllowanceTransfer.sol";
import { CommitmentTree } from "./CommitmentTree.sol";
import { AssetRegistry } from "./AssetRegistry.sol";
import { NullifierSet } from "./NullifierSet.sol";
import { UpgradeStorage } from "./UpgradeStorage.sol";
import { YieldIndex } from "./yield/YieldIndex.sol";
import { YieldOps } from "./yield/YieldOps.sol";
import { DepositOps } from "./libs/DepositOps.sol";
import { IVerifier } from "./interfaces/IVerifier.sol";
import { IBatchVerifier } from "./interfaces/IBatchVerifier.sol";
import { VerifierStorage } from "./VerifierStorage.sol";
import { PubInputs } from "./libs/PubInputs.sol";
import { AuxValidation } from "./libs/AuxValidation.sol";
import { Fees } from "./libs/Fees.sol";
import { ExitTerms } from "./libs/ExitTerms.sol";
/// Multi-Asset Shielded Pool. Entry points:
///
/// - `deposit` — escrow funds via Permit2; no SNARK at submit.
/// - `flushBatch` — insert up to `MAX_L_BATCH / LEAVES_PER_DEPOSIT` escrowed
/// deposits under one `tree_update_batch` SNARK.
/// - `cancelDeposit` — refund the digest-bound payer after `cancelDelay`.
/// - `transfer` / `withdraw` — spends; verify `(4x6, tree_update_batch)`.
///
/// Token movement binds to `payer` (signature- or SNARK-bound), not
/// `msg.sender`, so any relayer may submit on a user's behalf. ERC-20 only;
/// native-coin wrapping lives in `NativeAdapter`.
contract MASP is Initializable, CommitmentTree, AssetRegistry, NullifierSet, YieldIndex {
using SafeERC20 for IERC20;
using PubInputs for PubInputs.Transact;
using PubInputs for PubInputs.TreeUpdateBatch;
/// Upper-case accessor names are part of the external interface: the SDK and
/// the relayer read them by name.
/// Width of the per-token fee accumulators in `flushBatch`: one slot per
/// deposit, since a batch drains at most `MAX_L_BATCH / LEAVES_PER_DEPOSIT`
/// deposits and cannot touch more distinct tokens. Duplicated here because
/// Solidity rejects a library constant as a memory-array length; `initialize`
/// asserts it against the batch width.
uint256 private constant FEE_ACC_SLOTS = 4;
/// Output leaves per spend, i.e. `N_OUT` of the deployed transact shape.
/// `tpi.actualCount` counts leaves, so the spend path pins it to this.
///
/// The spend entry points spell their aux array `Output[6]` rather than
/// `Output[PubInputs.TRANSACT_OUT]`, as Solidity rejects a library-qualified
/// constant as an array length. Drift between the two fails to compile at
/// `PubInputs.compress` and `AuxValidation.validate`.
uint64 private constant TRANSACT_OUT_LEAVES = uint64(PubInputs.TRANSACT_OUT);
/// Uniswap Permit2. `initialize` reverts if the address holds no code.
ISignatureTransfer public PERMIT2;
struct Proof {
uint256[2] a;
uint256[2][2] b;
uint256[2] c;
}
/// Permit2 signature and the payer's signed ceilings, bounding fee drift
/// between signing and execution.
///
/// When the relayer note is in the deposit's own asset (or carries no
/// value) the payer signs a `PermitWitnessTransferFrom` over one token:
/// `maxTotal` caps the whole pull (`inAmt + fee + relayer note value`) and
/// `maxFee` must be zero. When it is in another asset the payer signs a
/// `PermitBatchWitnessTransferFrom` over `[deposit token, fee token]`, in
/// that order: `maxTotal` caps the deposit token's pull (`inAmt + fee`) and
/// `maxFee` the fee token's (the relayer note value).
struct Permit2Sig {
uint256 nonce;
uint256 deadline;
uint256 maxTotal;
uint256 maxFee;
bytes signature;
}
/// Permit2 witness binding. `piHash = keccak256(abi.encode(d, aux,
/// feeAux))`, so it covers `d.feeAssetId`. The inner
/// `MASPDeposit(bytes32 piHash)` of the type string must match the
/// typehash. The type string serves the single and the batch permit alike:
/// Permit2 prefixes its own primary-type stub to it. Defined in
/// `DepositOps`, which performs the two-token pull, and re-exposed here.
bytes32 public constant DEPOSIT_WITNESS_TYPEHASH = DepositOps.DEPOSIT_WITNESS_TYPEHASH;
string public constant DEPOSIT_WITNESS_TYPE_STRING = DepositOps.DEPOSIT_WITNESS_TYPE_STRING;
// ============== Escrow state =============================================
/// Per-deposit escrow digest (see `_depositDigest`). Pending iff nonzero.
/// All submit-time fields fold into the digest; flush and cancel resupply
/// the preimage from `DepositEscrowed`, and keccak equality binds it.
mapping(uint256 id => bytes32 digest) public escrowed;
uint256 public nextDepositId;
/// Digest fields not carried in `tpi`; verified against `escrowed[id]` in
/// `_drainDeposit`. Sourced from the deposit's `DepositEscrowed` event
/// (`submittedAt` = its block number).
struct DepositMeta {
address payer;
uint32 submittedAt;
uint16 fbps;
/// `DepositEscrowed.pulled`. A flush refunds nothing and so never
/// applies the cap, but the digest binds it.
uint256 pulled;
}
/// Blocks before `cancelDeposit` is allowed. Owner-tunable within
/// `[CANCEL_DELAY_MIN, CANCEL_DELAY_MAX]`; a raise waits out
/// `ExitTerms.DELAY`, see `setCancelDelay`.
uint32 public cancelDelay;
uint32 internal constant CANCEL_DELAY_MIN = 3_600; // ~12h on 12s blocks
uint32 internal constant CANCEL_DELAY_MAX = 50_400; // ~7d on 12s blocks
uint32 internal constant CANCEL_DELAY_DEFAULT = 7_200; // ~24h on 12s blocks
/// Emitted on shield (deposit) and unshield (withdraw); skipped for pure
/// transfers.
///
/// `inAmount` / `outAmount` are the ERC-20 base units that moved;
/// `publicIn` / `publicOut` are the circuit values the SNARK published. For
/// a plain asset the two differ by `scale`; for a yield asset the conversion
/// also applies the pool's index.
event AssetMoved(
uint64 indexed assetId,
IERC20 indexed token,
uint256 inAmount,
uint256 outAmount,
uint64 publicIn,
uint64 publicOut
);
/// Encrypted-note payload for spend flows (FMD clue, ephemeral pubkey,
/// ciphertext). Emitted once per output leaf. `cm` is the note commitment
/// and the tree leaf; it is indexed, so this also serves as the
/// note-creation signal for indexers that track commitments only.
event NotePayload(
bytes32 indexed cm, uint256 clueRx, uint256 clueRy, uint256 ephPubX, uint256 ephPubY, bytes ciphertext
);
/// Escrow-flow note signal. Carries the per-deposit payload a relayer needs
/// to assemble a `flushBatch`. `NotePayload` is not emitted on this path.
///
/// `inner` is not the tree leaf. The leaf a flush inserts for this deposit
/// is `Poseidon(TAG_CM, publicAssetId * 2^64 + publicIn, inner)`, computed
/// by the batch circuit and never on-chain; an indexer rebuilding the tree
/// computes it the same way, and likewise the fee note's from `feeAssetId`,
/// `feeIn` and `feeInner`.
event DepositEscrowed(
uint256 indexed id,
address indexed payer,
address indexed recipient,
uint64 publicAssetId,
uint64 publicIn,
uint16 feeBpsAtSubmit,
bytes32 inner,
uint256 clueRx,
uint256 clueRy,
uint256 ephPubX,
uint256 ephPubY,
bytes ciphertext,
// The relayer's fee note. Non-indexed: the three topic slots are taken,
// and a relayer locates its note by trial decryption, so indexing would
// publish the payee without aiding lookup. `feeAssetId` is 0 exactly
// when `feeIn` is 0.
uint64 feeAssetId,
uint64 feeIn,
bytes32 feeInner,
uint256 feeClueRx,
uint256 feeClueRy,
uint256 feeEphPubX,
uint256 feeEphPubY,
bytes feeCiphertext,
// A yield-asset escrow's refund cap: what was pulled for it in the
// deposit asset's token, which is the most a cancel returns (see
// `YieldOps.cancel`). Zero for a plain asset, whose refund is fixed by
// the other fields. Part of the digest preimage, so a flush or a cancel
// hands it back.
uint256 pulled
);
event DepositFlushed(uint256 indexed id, bytes32 inner);
/// `refunded` is paid in the deposit asset's token. `feeRefunded` is the
/// relayer note's value paid separately in `feeAssetId`'s token, nonzero
/// only when the note was in another asset; otherwise that value is part
/// of `refunded`.
event DepositCanceled(
uint256 indexed id, address indexed payer, uint256 refunded, uint64 feeAssetId, uint256 feeRefunded
);
event CancelDelayUpdated(uint32 oldDelay, uint32 newDelay);
// --- request validation (calldata + storage shape) ----------------------
error BadChainId();
error ZeroRecipient();
error ZeroPayer();
error BadRelayer();
// --- entry-point invariants ---------------------------------------------
error MustHaveDeposit();
error MustHaveWithdraw();
error MustNotHaveWithdraw();
/// A transfer withdraws nothing, so its `publicAssetId` must be 0.
error MustNotNameAsset();
// --- merkle / batch state ------------------------------------------------
error UnknownRoot();
error StaleOldRoot();
error BatchMisaligned();
error TreeFull();
// --- registry / proof verification ---------------------------------------
/// `permit2_` holds no code.
error ZeroPermit2();
error ProofRejected();
error TreeUpdateRejected();
// --- escrow flow ---------------------------------------------------------
error PublicInTooLarge();
error PublicOutTooLarge();
/// A deposit's `inner` or `feeInner` is zero.
error ZeroInner();
error DepositNotPending(uint256 id);
error BadBatchSize();
error CancelTooEarly(uint256 id, uint256 unlockBlock);
error BadCancelDelay();
error PayerNotSender();
error AmountOverflowsAllowance();
error BadDepositMode();
/// A zero-value relayer note must name asset 0.
error FeeAssetMustBeZero();
/// A valued relayer note names asset 0, or a yield asset other than the
/// deposit's own.
error FeeAssetUnsupported(uint64 id);
/// `Permit2Sig.maxFee` is set on a deposit whose relayer note is in the
/// deposit's own asset, where there is no second token to cap.
error BadMaxFee();
/// Caller-supplied digest preimage mismatch on flush/cancel.
error DigestMismatch(uint256 id);
/// Spends, flushes and new deposits are halted; see `whenNotPaused`.
error SpendsPaused(uint256 until);
/// Halts every entry point that verifies a proof or accepts new funds for
/// the duration of an admin pause (`DelayedUpgradeProxy.pauseSpends`).
///
/// A pause also blocks exits, so `DelayedUpgradeProxy` defers any pending
/// upgrade by the pause duration and the window measures unpaused time.
///
/// `cancelDeposit` and `sweep` remain open: neither verifies a proof, and
/// escrowed funds must stay recoverable.
modifier whenNotPaused() {
_requireNotPaused();
_;
}
/// Factored out of the modifier so its body is not inlined into each of the
/// five guarded entry points.
function _requireNotPaused() private view {
uint256 until = UpgradeStorage.spendsPausedUntil();
if (block.timestamp < until) revert SpendsPaused(until);
}
/// Locks the implementation, preventing it from being initialized outside a
/// proxy.
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
/// Runs once, against the proxy's storage. A constructor would write the
/// implementation's storage, leaving the proxy unowned, unconfigured and
/// without a seeded root.
function initialize(
IVerifier treeUpdateBatchVerifier_,
IBatchVerifier spendVerifier_,
ISignatureTransfer permit2_,
uint64[] memory ids,
IERC20[] memory tokens,
uint256[] memory scales,
uint16[] memory depositBps,
uint16[] memory withdrawBps,
address treasury_,
address owner_
) external initializer {
_initOwner(owner_);
_initCommitmentTree();
VerifierStorage.validatePair(address(treeUpdateBatchVerifier_), address(spendVerifier_));
if (address(permit2_).code.length == 0) revert ZeroPermit2();
// Deploy-time guard for the duplicated batch width; see `FEE_ACC_SLOTS`.
if (FEE_ACC_SLOTS * PubInputs.LEAVES_PER_DEPOSIT != PubInputs.MAX_L_BATCH) revert BadBatchSize();
VerifierStorage.Layout storage v = VerifierStorage.$();
v.treeUpdateBatchVerifier = address(treeUpdateBatchVerifier_);
v.spendVerifier = address(spendVerifier_);
PERMIT2 = permit2_;
_initTreasury(treasury_);
_writeAssets(ids, tokens, scales, depositBps, withdrawBps);
cancelDelay = CANCEL_DELAY_DEFAULT;
}
/// Proposes a new cancel delay, bounded here whether applied or queued.
///
/// `cancelDeposit` reads the delay from storage and the escrow digest binds
/// only `submittedAt`, so a change moves the unlock block of every escrow
/// already in flight. Shortening only frees funds sooner and applies at
/// once, dropping any queued raise. Lengthening would extend a lock the
/// payer did not accept, so it is queued in `ExitTerms` and lands through
/// `commitExitTerms`. `CancelDelayUpdated` is emitted when the live value is
/// written, here or at commit, not when a raise is queued.
function setCancelDelay(uint32 newDelay) external onlyOwner {
if (newDelay < CANCEL_DELAY_MIN || newDelay > CANCEL_DELAY_MAX) revert BadCancelDelay();
if (!YieldOps.proposeRaise(ExitTerms.$().cancelDelay, cancelDelay, newDelay, 0, ExitTerms.CANCEL_DELAY)) {
_applyCancelDelay(newDelay);
}
}
/// Applies every raise queued for `id` whose notice has run: its withdraw
/// rate, its performance-fee rate, and the pool-wide cancel delay, which any
/// id's commit carries. Raises not yet due stay queued; see
/// `YieldOps.commitExitTerms` for when it reverts.
///
/// Permissionless: the owner chose the value and the notice has been public
/// for its full length, so the call carries liveness only, like
/// `activateUpgrade`. Each commit emits the term's ordinary applied event
/// (`AssetFeeSet`, `CancelDelayUpdated`, `YieldParamsSet`), so indexers
/// following those need no separate handling.
///
/// One entry point for all three terms, with its logic in `YieldOps`, since
/// the pool sits close to the EIP-170 limit. The library applies the
/// performance fee and returns the other two, zero when not taken.
function commitExitTerms(uint64 id) external nonReentrant {
(uint256 withdrawBps, uint256 delay) = YieldOps.commitExitTerms(_y, id, _getAsset(id).scale);
// forge-lint: disable-next-line(unsafe-typecast)
if (withdrawBps != 0) _commitWithdrawBps(id, uint16(withdrawBps));
// forge-lint: disable-next-line(unsafe-typecast)
if (delay != 0) _applyCancelDelay(uint32(delay));
}
function _applyCancelDelay(uint32 newDelay) private {
emit CancelDelayUpdated(cancelDelay, newDelay);
cancelDelay = newDelay;
}
/// Verifier for `tree_update_batch.circom` (MAX_L = `PubInputs.MAX_L_BATCH`).
/// Used by `flushBatch`, which carries a lone tree-update proof; a spend's
/// tree-update proof is checked by `SPEND_VERIFIER`. "Batch" denotes a batch
/// of leaves, not a batched pairing.
///
/// Held in `VerifierStorage`, not in the pool's sequential layout: the
/// proxy installs a replacement and would otherwise need a copy of that
/// layout to do it. Declared as an explicit function to provide the
/// upper-case accessor name; see the note at the top of the contract.
function TREE_UPDATE_BATCH_VERIFIER() external view returns (IVerifier) {
return VerifierStorage.treeUpdateBatchVerifier();
}
/// Checks the `(4x6, tree_update_batch)` proof pair a spend carries in one
/// BN254 pairing call. This is the whole of a spend's proof check: the pool
/// holds no standalone `4x6` verifier. See `_verifyProofs` for the argument
/// order and the rejection error.
///
/// Held in `VerifierStorage`; see `TREE_UPDATE_BATCH_VERIFIER`.
function SPEND_VERIFIER() external view returns (IBatchVerifier) {
return VerifierStorage.spendVerifier();
}
/// The rates `id` charges on each leg. Reverts `UnknownAsset` for an
/// unregistered id.
function assetFees(uint64 id) external view returns (uint16 depositBps, uint16 withdrawBps) {
AssetEntry memory a = _getAsset(id);
if (address(a.token) == address(0)) revert UnknownAsset(id);
return (a.depositBps, a.withdrawBps);
}
/// Registers an asset whose custody earns in `venue`.
///
/// The venue binding is permanent: `_addAsset` reverts `DuplicateAsset` on
/// an existing id, so `params[id].venue` is written exactly once, and there
/// is no `setVenue`. Replacing a venue means registering a new id. The
/// entry's `isYield` flag, which the pool's own paths branch on, is written
/// in the same call and is as permanent.
///
/// Yield is a property of the asset id, not of the note: the circuit sees
/// only `publicAssetId`, and every note under an id shares one index. The
/// plain id for the same token remains unlent custody, so a depositor opts
/// in or out by choosing an id.
///
/// Registration happens after deployment because a venue is pinned to this
/// pool's address.
function addYieldAsset(
uint64 id,
IERC20 token,
uint256 scale,
uint16 depositBps,
uint16 withdrawBps,
address venue_,
uint16 bufferBps_,
uint16 perfBps_
) external onlyOwner {
_addAsset(id, token, scale, depositBps, withdrawBps, true);
_initYieldAsset(id, token, venue_, bufferBps_, perfBps_);
}
/// `YieldIndex`'s view of the registry, used only by its external entry
/// points; hot paths pass `scale` down from the `AssetEntry` they hold.
function _yieldAsset(uint64 id) internal view override returns (IERC20 token, uint256 scale) {
AssetEntry memory a = _getAsset(id);
if (address(a.token) == address(0)) revert UnknownAsset(id);
return (a.token, a.scale);
}
/// ERC-20 unshield. The pool pushes `outAmt - fee` to `pi.recipient`.
function withdraw(
Proof calldata p,
PubInputs.Transact calldata pi,
Proof calldata tp,
PubInputs.SpendTree calldata tpi,
AuxValidation.Output[6] calldata aux
) external nonReentrant whenNotPaused {
if (pi.publicOut == 0) revert MustHaveWithdraw();
AssetEntry memory a = _preflight(pi, tpi, aux);
_finalize(p, pi, tp, tpi, aux);
uint256 outAmt;
if (!a.isYield) {
outAmt = uint256(pi.publicOut) * a.scale;
_unshieldLeg(a.token, pi.recipient, outAmt, a.withdrawBps);
} else {
outAmt = YieldOps.unshield(
_y, pi.publicAssetId, a.token, a.scale, a.withdrawBps, pi.recipient, uint256(pi.publicOut)
);
}
emit AssetMoved(pi.publicAssetId, a.token, 0, outAmt, 0, pi.publicOut);
_emitNotes(pi, aux);
}
/// Internal transfer between shielded notes. No token movement.
function transfer(
Proof calldata p,
PubInputs.Transact calldata pi,
Proof calldata tp,
PubInputs.SpendTree calldata tpi,
AuxValidation.Output[6] calldata aux
) external nonReentrant whenNotPaused {
if (pi.publicOut != 0) revert MustNotHaveWithdraw();
// No tokens move and no asset is named: the circuit forces
// `publicAssetId` to 0 whenever `publicOut` is, so a transfer does not
// publish the asset it moves. Checked here so a request naming an asset
// fails with a reason rather than as a rejected proof.
if (pi.publicAssetId != 0) revert MustNotNameAsset();
_validateRequest(pi, tpi, aux);
_finalize(p, pi, tp, tpi, aux);
_emitNotes(pi, aux);
}
// ============== Escrow flow ==============================================
/// Pulls funds into escrow via Permit2; no SNARK at submit. Relayers read
/// `DepositEscrowed` to assemble a `flushBatch`. An `inner` that is not the
/// hash of a note's owner fields costs only the depositor: the leaf then
/// has no opening.
function deposit(
PubInputs.DepositRequest calldata d,
Permit2Sig calldata sig,
AuxValidation.Output calldata aux,
AuxValidation.Output calldata feeAux
) external nonReentrant whenNotPaused returns (uint256 id) {
(AssetEntry memory a, AssetEntry memory fa) = _validateDeposit(d, aux, feeAux);
Shield memory s = _quoteShield(d, a, fa);
bytes32 piHash = keccak256(abi.encode(d, aux, feeAux));
// `s.feePull` carries `_quoteShield`'s `feeInDepositAsset` decision:
// zero on the single-token path, whose pull stays inline so the common
// deposit pays no library call; nonzero on the two-token path, which
// runs in `DepositOps`.
if (s.feePull == 0) {
if (sig.maxFee != 0) revert BadMaxFee();
_permit2Pull(a.token, d.payer, sig, s.total, piHash);
} else {
DepositOps.pullSignedBatch(
PERMIT2,
d.payer,
a.token,
s.total,
fa.token,
s.feePull,
sig.nonce,
sig.deadline,
sig.maxTotal,
sig.maxFee,
sig.signature,
piHash
);
}
_settleShield(d.publicAssetId, a, s);
id = _finalizeDeposit(d, aux, feeAux, a, s, a.depositBps);
}
/// Permit2 AllowanceTransfer deposit. The user pre-signs a `PermitSingle`
/// (or a `PermitBatch`, when the relayer note is in another asset)
/// covering N future deposits; each pulls via `transferFrom` with no
/// per-transaction signature. Requires `msg.sender == d.payer`; Permit2
/// enforces the signed caps and expirations, per token.
function depositAuthorized(
PubInputs.DepositRequest calldata d,
AuxValidation.Output calldata aux,
AuxValidation.Output calldata feeAux
) external nonReentrant whenNotPaused returns (uint256 id) {
(AssetEntry memory a, AssetEntry memory fa) = _validateDeposit(d, aux, feeAux);
if (msg.sender != d.payer) revert PayerNotSender();
Shield memory s = _quoteShield(d, a, fa);
if (s.total > type(uint160).max || s.feePull > type(uint160).max) revert AmountOverflowsAllowance();
// Branches as `deposit` does; see there.
if (s.feePull == 0) {
// forge-lint: disable-next-line(unsafe-typecast)
IAllowanceTransfer(address(PERMIT2))
.transferFrom(d.payer, address(this), uint160(s.total), address(a.token));
} else {
DepositOps.pullAuthorizedBatch(
IAllowanceTransfer(address(PERMIT2)), d.payer, a.token, s.total, fa.token, s.feePull
);
}
_settleShield(d.publicAssetId, a, s);
id = _finalizeDeposit(d, aux, feeAux, a, s, a.depositBps);
}
/// A priced shield, carried between the quote and the pull. The two deposit
/// entry points differ only in how tokens move, so pricing and settlement
/// are shared.
struct Shield {
/// What the payer is charged in the deposit token: principal and
/// treasury fee, plus the relayer note when it is in the same asset.
uint256 total;
/// The relayer note's value in the fee asset's token, charged
/// separately. Zero exactly when `PubInputs.feeInDepositAsset` holds,
/// so the deposit entry points branch on it.
uint256 feePull;
/// Principal alone, which is what `AssetMoved` reports.
uint256 inAmt;
/// Normalized units the pool takes on. Unused for a plain asset.
uint256 units;
/// `gross` as of the quote, so settlement need not read the venue
/// again.
uint256 grossBefore;
}
/// Prices a shield under the asset's arithmetic.
///
/// The indexed branch charges its fee in normalized units and converts once
/// on the total, rounding on a coarser grid than the plain branch; hence the
/// unit fee rounds up where the plain fee floors. See `Fees.unitFee`.
///
/// A relayer note in another asset is priced under that asset's `scale` and
/// kept out of the principal's quote: the yield branch is passed `feeIn = 0`,
/// so no fee units join the principal's `totalNormalized`, and the escrow's
/// refund cap (`DepositEscrowed.pulled`) covers the principal's pull alone.
/// `cancelDeposit` splits the refund the same way.
function _quoteShield(PubInputs.DepositRequest calldata d, AssetEntry memory a, AssetEntry memory fa)
private
returns (Shield memory s)
{
uint256 feeIn = d.feeIn;
if (!PubInputs.feeInDepositAsset(feeIn, d.feeAssetId, d.publicAssetId)) {
s.feePull = _relayerAmount(feeIn, fa.scale);
feeIn = 0;
}
if (!a.isYield) {
(uint256 inAmt, uint256 fee) = _computeAmounts(d.publicIn, a.scale, a.depositBps);
s.inAmt = inAmt;
s.total = inAmt + fee + _relayerAmount(feeIn, a.scale);
} else {
(s.total, s.inAmt, s.units, s.grossBefore) =
YieldOps.quoteShield(_y, d.publicAssetId, a.scale, a.depositBps, d.publicIn, feeIn);
}
}
/// Books a pulled shield. A plain asset has nothing to book: its backing is
/// the pool's balance and its fee does not accrue until flush.
function _settleShield(uint64 assetId, AssetEntry memory a, Shield memory s) private {
if (!a.isYield) return;
YieldOps.settleShield(_y, assetId, a.token, s.total, s.units, s.grossBefore);
}
/// Shared validation for `deposit*`. Returns the deposit asset's entry and,
/// when the relayer note is charged in another asset, that asset's entry
/// (zeroed otherwise).
///
/// Nothing is opened here. The digest records `feeAssetId` and `feeIn`
/// beside `feeInner`, and at flush the batch circuit builds the fee leaf
/// from exactly those three.
function _validateDeposit(
PubInputs.DepositRequest calldata d,
AuxValidation.Output calldata aux,
AuxValidation.Output calldata feeAux
) private view returns (AssetEntry memory a, AssetEntry memory fa) {
if (d.chainId != block.chainid) revert BadChainId();
if (d.publicIn == 0) revert MustHaveDeposit();
if (d.publicIn > type(uint48).max) revert PublicInTooLarge();
if (d.payer == address(0)) revert ZeroPayer();
if (d.recipient == address(0)) revert ZeroRecipient();
if (d.inner == bytes32(0)) revert ZeroInner();
// The fee note's value may be zero, but the leaf must be well formed.
// `_drainDeposit` narrows this to uint48, as it does `publicIn`.
if (d.feeIn > type(uint48).max) revert PublicInTooLarge();
if (d.feeInner == bytes32(0)) revert ZeroInner();
a = _getAsset(d.publicAssetId);
if (address(a.token) == address(0)) revert UnknownAsset(d.publicAssetId);
if (a.disabled) revert AssetDisabled(d.publicAssetId);
// The fee note's asset. A zero-value note is charged in nothing, so it
// must name asset 0, "no asset"; the circuit accepts any id on a
// zero-value leaf, and this keeps one canonical form. A note charged in
// another asset (for a valued note, `PubInputs.feeInDepositAsset`
// reduces to the id comparison below) must be a live, plain registry
// asset: a yield asset would need its units booked into that asset's
// `totalNormalized`, which only the deposit asset gets.
if (d.feeIn == 0) {
if (d.feeAssetId != 0) revert FeeAssetMustBeZero();
} else if (d.feeAssetId != d.publicAssetId) {
fa = _getAsset(d.feeAssetId);
if (d.feeAssetId == 0 || fa.isYield) revert FeeAssetUnsupported(d.feeAssetId);
if (address(fa.token) == address(0)) revert UnknownAsset(d.feeAssetId);
if (fa.disabled) revert AssetDisabled(d.feeAssetId);
}
AuxValidation.validate(aux);
AuxValidation.validate(feeAux);
}
/// Shared escrow record and event emission for `deposit*`. The caller must
/// already have pulled `s.total` of `a.token` and `s.feePull` of the fee
/// asset's token into the pool. Only the treasury's `fee` accrues, and only
/// at flush; the relayer's portion stays principal backing its note, in
/// whichever token it was paid.
function _finalizeDeposit(
PubInputs.DepositRequest calldata d,
AuxValidation.Output calldata aux,
AuxValidation.Output calldata feeAux,
AssetEntry memory a,
Shield memory s,
uint16 fbps
) private returns (uint256 id) {
unchecked {
id = nextDepositId++;
}
// The refund cap, `DepositEscrowed.pulled`: bound by the digest and
// published in the event rather than stored.
uint256 pulled = a.isYield ? s.total : 0;
// forge-lint: disable-next-line(unsafe-typecast)
escrowed[id] = _depositDigest(
id,
d.inner,
d.publicAssetId,
uint48(d.publicIn),
fbps,
d.payer,
// forge-lint: disable-next-line(unsafe-typecast)
uint32(block.number),
PubInputs.FeeNote({
// forge-lint: disable-next-line(unsafe-typecast)
feeIn: uint48(d.feeIn),
feeAssetId: d.feeAssetId,
feeInner: d.feeInner
}),
pulled
);
emit DepositEscrowed(
id,
d.payer,
d.recipient,
d.publicAssetId,
d.publicIn,
fbps,
d.inner,
aux.clueRx,
aux.clueRy,
aux.ephPubX,
aux.ephPubY,
aux.ciphertext,
d.feeAssetId,
d.feeIn,
d.feeInner,
feeAux.clueRx,
feeAux.clueRy,
feeAux.ephPubX,
feeAux.ephPubY,
feeAux.ciphertext,
pulled
);
emit AssetMoved(d.publicAssetId, a.token, s.inAmt, 0, d.publicIn, 0);
}
/// Inserts escrowed deposits under one batched SNARK. `tpi` and `meta`
/// mirror the per-deposit payloads in `ids` order; the circuit enforces
/// `isDeposit` on active slots and zeros for padding.
///
/// A deposit occupies `LEAVES_PER_DEPOSIT` adjacent leaves — its principal
/// and the note paying the flusher — so deposit `i` owns leaves `2i` and
/// `2i + 1` and the batch advances the tree by `2n`. One batch holds
/// `MAX_L_BATCH / LEAVES_PER_DEPOSIT` deposits.
function flushBatch(
uint256[] calldata ids,
DepositMeta[] calldata meta,
Proof calldata tp,
PubInputs.TreeUpdateBatch calldata tpi
) external nonReentrant whenNotPaused {
uint256 n = ids.length;
if (meta.length != n) revert BadBatchSize();
_validateBatchHeader(n, tpi);
// Per-token fee accumulators; see `FEE_ACC_SLOTS`.
// slither-disable-next-line uninitialized-local
IERC20[FEE_ACC_SLOTS] memory tokens;
// slither-disable-next-line uninitialized-local
uint256[FEE_ACC_SLOTS] memory fees;
uint256 nUnique = 0;
// Phase 1: drain each deposit and accumulate its fee per token.
for (uint256 i = 0; i < n; ++i) {
nUnique = _drainDeposit(ids[i], i, tpi, meta[i], tokens, fees, nUnique);
}
// Phase 2: accrue fees, one SSTORE per unique token.
for (uint256 j = 0; j < nUnique; ++j) {
_accrueFee(tokens[j], fees[j]);
}
// Phase 3: verify the batch SNARK and advance the tree by
// `n * LEAVES_PER_DEPOSIT` leaves.
if (!VerifierStorage.treeUpdateBatchVerifier().verifyProof(tp.a, tp.b, tp.c, tpi.compress())) {
revert TreeUpdateRejected();
}
unchecked {
_advanceRoot(tpi.newRoot, uint64(n * PubInputs.LEAVES_PER_DEPOSIT), tpi.oldRoot);
}
}
/// Batch-level header checks: size, count alignment, tree position. `n`
/// counts deposits; the batch commits `LEAVES_PER_DEPOSIT * n` leaves.
function _validateBatchHeader(uint256 n, PubInputs.TreeUpdateBatch calldata tpi) private view {
uint256 leaves = n * PubInputs.LEAVES_PER_DEPOSIT;
if (n == 0 || leaves > PubInputs.MAX_L_BATCH) revert BadBatchSize();
if (uint256(tpi.actualCount) != leaves) revert BatchMisaligned();
_requireTreePosition(tpi, leaves);
}
/// Places a flush batch at the tree's frontier: it must extend the live
/// root, start at `committedCount`, and fit. The spend path applies the
/// same position and capacity checks in `_validateRequest` and binds
/// `oldRoot` through the proof image.
function _requireTreePosition(PubInputs.TreeUpdateBatch calldata tpi, uint256 leaves) private view {
if (tpi.oldRoot != currentRoot()) revert StaleOldRoot();
uint64 cc = committedCount;
if (tpi.startIndex != cc) revert BatchMisaligned();
if (uint256(cc) + leaves > MAX_LEAVES) revert TreeFull();
}
/// Validates one deposit against its `tpi` slots and `meta`, accumulates
/// its fee under its token, then emits and deletes the escrow. Returns the
/// updated unique-token count. Digest equality binds every calldata field,
/// asset included.
function _drainDeposit(
uint256 id,
uint256 i,
PubInputs.TreeUpdateBatch calldata tpi,
DepositMeta calldata m,
IERC20[FEE_ACC_SLOTS] memory tokens,
uint256[FEE_ACC_SLOTS] memory fees,
uint256 nUnique
) private returns (uint256) {
bytes32 stored = escrowed[id];
// Zero is the sentinel for "no pending deposit": a presence test, not
// arithmetic on an attacker-movable quantity. The `delete` below
// restores it, rejecting a repeated id within one batch.
// slither-disable-next-line incorrect-equality
if (stored == bytes32(0)) revert DepositNotPending(id);
// Deposit `i`'s two leaves: its principal, then the note paying the
// flusher. Both are deposit leaves to the circuit, which builds each
// from that slot's own `leafAsset`, `leafPublicIn` and `cms` (the
// depositor's `inner`). Pinning `isDeposit` to 1 is what makes it do so:
// with the flag clear the circuit inserts `cms` as it stands, and a
// depositor who escrowed a commitment of its choosing in place of
// `inner` would hold a note of any value.
uint256 p = i * PubInputs.LEAVES_PER_DEPOSIT;
uint256 f = p + 1;
if (tpi.isDeposit[p] != 1 || tpi.isDeposit[f] != 1) revert BadDepositMode();
if (tpi.leafPublicIn[p] > type(uint48).max) revert PublicInTooLarge();
if (tpi.leafPublicIn[f] > type(uint48).max) revert PublicInTooLarge();
// The fee note's asset, `leafAsset[f]`, enters the digest below as
// `FeeNote.feeAssetId`, so it is the `feeAssetId` validated at submit,
// and the circuit hashes it into the fee leaf beside `leafPublicIn[f]`.
//
// A note in another asset than the principal was pulled at submit as
// `feeIn * scale` of a plain asset's token and backs the note as
// custody; nothing is accrued or booked for it here.
// Reconstruct the submit-time digest; one equality binds all fields.
uint64 assetId = tpi.leafAsset[p];
// forge-lint: disable-next-line(unsafe-typecast)
bytes32 expected = _depositDigest(
id,
tpi.cms[p],
assetId,
uint48(tpi.leafPublicIn[p]),
m.fbps,
m.payer,
m.submittedAt,
PubInputs.FeeNote({
// forge-lint: disable-next-line(unsafe-typecast)
feeIn: uint48(tpi.leafPublicIn[f]),
feeAssetId: tpi.leafAsset[f],
feeInner: tpi.cms[f]
}),
m.pulled
);
if (expected != stored) revert DigestMismatch(id);
AssetEntry memory a = _getAsset(assetId);
// Only the treasury's cut is accrued; the relayer's stays pool
// principal (see `_relayerAmount`).
uint256 newCount;
if (!a.isYield) {
(, uint256 fee) = _computeAmounts(uint256(tpi.leafPublicIn[p]), a.scale, uint256(m.fbps));
uint256 slot;
(slot, newCount) = _findOrAppendToken(tokens, nUnique, a.token);
fees[slot] += fee;
} else {
// Recomputed in normalized units, without `scale` or the index, so
// it reproduces what submit charged and keeps the index out of the
// escrow digest.
//
// Settled per deposit rather than batched: the accumulator is keyed
// by ERC-20, and a plain id and a yield id may share one token.
uint256 nFee = Fees.unitFee(uint256(tpi.leafPublicIn[p]), uint256(m.fbps));
if (nFee != 0) {
// Supply-neutral: the units move from holders to the treasury
// rather than being created.
_y.totalNormalized[assetId] -= nFee;
_y.accruedFeeNormalized[assetId] += nFee;
}
newCount = nUnique;
}
emit DepositFlushed(id, tpi.cms[p]);
delete escrowed[id];
return newCount;
}
/// Preimage for `escrowed[id]`, shared by submit, flush and cancel.
/// `(address(this), block.chainid, id)` is the anti-replay prefix.
function _depositDigest(
uint256 id,
bytes32 inner,
uint64 publicAssetId,
uint48 publicIn,
uint16 feeBpsAtSubmit,
address payer,
uint32 submittedAt,
PubInputs.FeeNote memory fee,
uint256 pulled
) private view returns (bytes32) {
return keccak256(
abi.encode(
address(this),
block.chainid,
id,
inner,
publicAssetId,
publicIn,
feeBpsAtSubmit,
payer,
submittedAt,
// The relayer's leaf is bound like the depositor's:
// `flushBatch` supplies both from calldata, so an unbound fee
// note would let a flusher mint itself an arbitrary one.
// `FeeNote` is static and encodes to three words in place:
// `feeIn`, `feeAssetId`, `feeInner`.
fee,
// The refund cap. Bound so that a canceller cannot choose it.
pulled
)
);
}
function _findOrAppendToken(IERC20[FEE_ACC_SLOTS] memory tokens, uint256 nUnique, IERC20 token)
private
pure
returns (uint256 slot, uint256 newCount)
{
for (uint256 j = 0; j < nUnique; ++j) {
if (address(tokens[j]) == address(token)) return (j, nUnique);
}
tokens[nUnique] = token;
return (nUnique, nUnique + 1);
}
/// Refunds the digest-bound payer after `cancelDelay`. The caller
/// resupplies the digest preimage from the deposit's `DepositEscrowed` event
/// (`submittedAt` = its block number); keccak equality binds every value to
/// submit time.
///
/// Permissionless for EOA payers, since a Permit2 signer need never send a
/// transaction itself. Contract payers must call this themselves: the refund
/// goes to the payer, not to whoever funded it, so the payer must observe it
/// arriving. `NativeAdapter` is such a payer — a refund delivered by a
/// third-party call is indistinguishable on-chain from a flushed deposit and
/// would strand the funder's claim.
///
/// Returns the refund so a contract payer can check the amounts that
/// arrived against the amounts the pool paid: `total` in the deposit
/// asset's token and `feeRefunded` in `feeNote.feeAssetId`'s. `feeRefunded`
/// is nonzero only when the relayer note was paid in another asset;
/// otherwise its value is part of `total`.
///
/// `pulled` is `DepositEscrowed.pulled`, the refund cap.
function cancelDeposit(
uint256 id,
uint48 publicIn,
bytes32 inner,
uint64 publicAssetId,
uint16 fbps,
address payer,
uint32 submittedAt,
PubInputs.FeeNote calldata feeNote,
uint256 pulled
) external nonReentrant returns (uint256 total, uint256 feeRefunded) {
bytes32 stored = escrowed[id];
if (stored == bytes32(0)) revert DepositNotPending(id);
bytes32 expected = _depositDigest(id, inner, publicAssetId, publicIn, fbps, payer, submittedAt, feeNote, pulled);
if (expected != stored) revert DigestMismatch(id);
// `payer` is digest-verified above, so this is the real payer, not a
// caller-chosen one. An EIP-7702 delegated EOA carries code and is
// treated as a contract payer: it loses relayed cancellation, not
// access.
if (payer.code.length != 0 && msg.sender != payer) revert PayerNotSender();
// `submittedAt` is digest-verified, so it is sound for the delay check.
uint256 unlockBlock = uint256(submittedAt) + uint256(cancelDelay);
if (block.number < unlockBlock) revert CancelTooEarly(id, unlockBlock);
// The relayer's portion refunds with the rest: no leaf was minted, so
// the fee was never earned. It refunds in the token it was paid in,
// split as `_quoteShield` split the pull: with the principal when the
// note is in the deposit asset, separately otherwise. Submit accepts
// only a plain asset for a note in another asset, so that refund is the
// fixed amount pulled.
uint256 relayerIn = feeNote.feeIn;
// Assigned and read only on the two-token path (`feeRefunded != 0`).
// slither-disable-next-line uninitialized-local
AssetEntry memory fa;
if (!PubInputs.feeInDepositAsset(relayerIn, feeNote.feeAssetId, publicAssetId)) {
fa = _getAsset(feeNote.feeAssetId);
feeRefunded = _relayerAmount(relayerIn, fa.scale);
relayerIn = 0;
}
AssetEntry memory a = _getAsset(publicAssetId);
if (!a.isYield) {
(uint256 inAmt, uint256 fee) = _computeAmounts(uint256(publicIn), a.scale, uint256(fbps));
total = inAmt + fee + _relayerAmount(relayerIn, a.scale);
} else {
// Refunded at the current index, capped at `pulled`, the amount
// taken at submit and digest-verified above: an escrow shares
// losses but earns nothing. See `YieldOps.cancel`.
//
// This external call precedes the `escrowed` clear below. The
// reentrancy guard is contract-wide, so the cross-function
// reentrancy slither reports here is unreachable: re-entry through
// the venue or the token reverts before it can observe the stale
// entry.
// slither-disable-next-line reentrancy-no-eth
total = YieldOps.cancel(
_y, publicAssetId, a.token, a.scale, uint256(publicIn), uint256(fbps), relayerIn, pulled
);
}
// CEI: clear escrow before external transfers.
delete escrowed[id];
a.token.safeTransfer(payer, total);
if (feeRefunded != 0) fa.token.safeTransfer(payer, feeRefunded);
emit DepositCanceled(id, payer, total, feeNote.feeAssetId, feeRefunded);
}
// ============== Internal helpers =========================================
/// `inAmt = publicIn * scale`, `fee = inAmt * fbps / BPS_DENOMINATOR`.
function _computeAmounts(uint256 publicIn, uint256 scale, uint256 fbps)
private
pure
returns (uint256 inAmt, uint256 fee)
{
inAmt = publicIn * scale;