Solidity contracts for a Multi-Asset Shielded Pool (MASP), built with Foundry.
Requires Foundry and Solidity ^0.8.30.
git clone <repo>
cd contracts
forge installAll commands are wrapped in the justfile; run just to list them.
just build # forge build
just test # forge test -vvv
just test-fuzz # heavy fuzz + invariant sweep (nightly profile)
just fmt # forge fmt
just size # EIP-170 size check under the deploy profile
just ci # version + build + test + fmt-check + sizeBuild profiles (see foundry.toml): default (via-IR, optimizer_runs = 1_000_000), fast (quick local iteration), deploy (optimizer_runs = 10_000, keeps MASP under the EIP-170 limit), fuzz (nightly depth), smt (opt-in model checker).
Test vectors live in test/fixtures/ (see its README):
asset_registry.json— asset generators for MASP deployproof_deposit_batch_n1.json— sample batched tree-update proofproof_transfer.json— sample transfer prooftree.json/empty_root.json— Merkle tree expected roots
Fixtures are generated by the circuits crate at ../circuits. Regenerate with just gen-fixtures whenever hash parameters, tree depth, or the generator set change. Proof fixtures require ../circuits/build/tree_update_batch_final.zkey (build with cd ../circuits && just rebuild-batch); override the location with just --set CIRCUITS_BUILD /abs/path gen-fixtures.
Four deploy scripts, split into MASP-core and swap-stack pairs. Mainnet deploys run under FOUNDRY_PROFILE=deploy.
| Script | Target | Command |
|---|---|---|
script/DeployTest.s.sol |
anvil MASP core | just anvil, then just deploy-anvil |
script/DeployTestSwap.s.sol |
anvil swap stack | just deploy-test-swap (after deploy-anvil) |
script/Deploy.s.sol |
mainnet MASP core | just deploy-mainnet --rpc-url $RPC --ledger |
script/DeploySwap.s.sol |
mainnet swap stack | just deploy-swap --rpc-url $RPC --ledger (after deploy-mainnet) |
DeployTestdeploysVerifier,TreeUpdateBatchVerifier,MASP, Permit2 (etched at its canonical address),MockWETH9, andMockERC20s seeded fromasset_registry.json, signing with anvil dev key #0.DeployTestSwapreadsMASP,PERMIT2,TOKEN_1..3from env — export them viaeval "$(just deploy-anvil | e2e/deploy/extract-addresses.sh)".Deployreadsscript/config/mainnet.json(override withMAINNET_CONFIG);DeploySwapreadsscript/config/mainnet.swap.json(override withSWAP_CONFIG). Arbitrum configs live alongside;*.example.jsonfiles document the schema.just dry-run-mainnet/just dry-run-swapsimulate without broadcasting.
Shared logic lives in script/base/BaseDeploy.s.sol and script/base/BaseSwapDeploy.s.sol.
From forge test --gas-report (default profile; reproduce locally):
| Function | Min | Avg | Max |
|---|---|---|---|
submitIntent |
28 176 | 161 266 | 231 424 |
flushBatch |
34 215 | 181 104 | 203 541 |
cancelIntent |
27 629 | 60 310 | 77 269 |
transfer |
41 837 | 51 153 | 62 779 |
submitIntentcovers the per-deposit hot path: Permit2permitWitnessTransferFrom(~75 k), aux validation, a 2-slotEscrowedIntentwrite, and theIntentEscrowedlog (~1.2 KB ciphertext). No SNARK verification on this leg.flushBatchamortizes one batchedTreeUpdateBatchVerifier.verifyProof(195 026) plus the Merkle root advance across up toMAX_N_BATCHintents; each intent's submit-time digest is rebuilt from calldata and checked against a single storage slot.cancelIntentrefunds escrow aftercancelDelay, verifying the caller-supplied digest preimage againstescrowed[id].digest.- Both Groth16 verifiers cost 195 026 per
verifyProofcall regardless of logical public-input count (PolyEval compresses to(y, z)).
Fee accounting stays off the hot path: submitIntent parks fees in pendingEscrowFee[token] (refundable on cancel), flushBatch moves them to accruedFee[token], and sweep(token) drains accrued fees to the treasury.
Check contract sizes with just size (EIP-170 enforcement under the deploy profile) or just build-sizes.
Selector → error mapping for MASP.sol and its libraries. For parameterized errors, the calldata payload follows the selector; decode with cast 4byte <selector> or cast sig "ErrorName()".
| Selector | Error | Cause |
|---|---|---|
0x93c3e55a |
BadChainId() |
chainId != block.chainid |
0xd27b4443 |
ZeroRecipient() |
recipient == address(0) |
0xa26bef69 |
ZeroPayer() |
payer == address(0) |
0x9d799ba3 |
ZeroRelayer() |
relayer == address(0) |
0x89a545e0 |
BadRelayer() |
relayer != msg.sender (anti front-run) |
0xeb70e79e |
CmMismatch() |
Spend-path only: pi.outCm[k] != tpi.cms[k] (transact vs tree-update binding) |
0x183be5d0 |
DigestMismatch(uint256 id) |
Flush/cancel: caller-supplied digest preimage does not match escrowed[id].digest |
0x17e37b5c |
BatchLengthMismatch() |
flushBatch: feeBpsAtSubmit.length != ids.length |
0xcc34802d |
MustHaveDeposit() |
submitIntent called with publicIn == 0 |
0x58b94be0 |
MustNotHaveDeposit() |
Transfer-only path called with publicIn != 0 |
0x4a2aaa61 |
MustHaveWithdraw() |
Withdraw path called with publicOut == 0 |
0x8e057901 |
MustNotHaveWithdraw() |
Non-withdraw path called with publicOut != 0 |
0x797649ab |
PublicInTooLarge() |
publicIn > type(uint48).max |
0xc4479fc4 |
ZeroCm() |
outCm[0] == 0 or outCm[1] == 0 |
0xdc704b92 |
IntentNotPending(uint256 id) |
flushBatch/cancelIntent references missing or already-drained id |
0x8004762e |
BadBatchSize() |
ids.length == 0 or > MAX_N_BATCH |
0xdf5426b8 |
CancelTooEarly(uint256 id, uint256 unlockBlock) |
cancelIntent before submittedAt + cancelDelay |
0x50112c2f |
BadCancelDelay() |
setCancelDelay value out of bounds |
0x8c520116 |
UnknownRoot() |
Merkle root not in known-roots ring buffer |
0x5fa73a84 |
StaleOldRoot() |
tpi.oldRoot != currentRoot() |
0x13f9eaf8 |
BatchMisaligned() |
tpi.startIndex != committedCount or actualCount != ids.length |
0x4a8bdce3 |
ZeroVerifier() |
Verifier addr not configured |
0x4581bae5 |
ZeroPermit2() |
Permit2 addr not configured |
0xc3b0d8cd |
ProofRejected() |
Transfer Groth16 verifier returned false |
0x0e5faa8e |
TreeUpdateRejected() |
Batched tree-update Groth16 verifier returned false |
0x987e7ca2 |
AssetNotWeth() |
Native-withdraw asset id resolves to a token other than configured WETH |
0x649607cd |
WethNotConfigured() |
Native-withdraw called but WETH immutable is address(0) |
0x43933b59 |
NotAWithdraw() |
Native-withdraw called with publicIn != 0 |
0x6d963f88 |
EthTransferFailed() |
Recipient or treasury rejected raw ETH on unshield leg |
0x52dac77b |
UnauthorizedEthSender() |
Raw ETH push to MASP from any sender other than configured WETH |
0xfced7caf |
UnknownAsset(uint64 id) |
publicAssetId not in _assets |
0xdf4d2119 |
DuplicateAsset(uint64 id) |
setAssets re-registers existing id |
0xad1991f5 |
ZeroToken() |
Asset registered with zero ERC20 address |
0x06b7de36 |
ZeroScale() |
Asset registered with zero scale |
0xff633a38 |
LengthMismatch() |
Array-arg length mismatch in admin setters |
0x9c707f95 |
ZeroTreasury() |
Treasury set to zero while fee > 0 |
0xcd4e6167 |
FeeTooHigh() |
feeBps exceeds max bound |
0xeb8ee076 |
PendingFeeUnderflow() |
Internal: _subPendingEscrowFee accounting drift |
0xa4c9b9c5 |
DoubleSpend() |
Nullifier already consumed |
0xe1200f1d |
DuplicateNullifier() |
nullifier[0] == nullifier[1] within same tx |
0xb1e4190b |
CiphertextTooLong() |
Output ciphertext exceeds max bytes |
0x08a09e03 |
BadClueBits() |
FMD clue bits malformed |
0x1bf8080d |
OffCurvePoint() |
Clue R or ECDH ephemeral pub not on Baby-Jubjub |
@openzeppelin/contracts,forge-std,poseidon-solidity,permit2
For local deploys, Permit2 is etched at its canonical address (0x000000000022D473030F116dDEE9F6B43aC78BA3) via the precompiled-bytecode DeployPermit2 helper — avoids compiling Permit2.sol (pinned =0.8.17) against project sources at ^0.8.30.
TBD