Skip to content

Repository files navigation

Lelantos Contracts

Solidity contracts for a Multi-Asset Shielded Pool (MASP), built with Foundry.

Setup

Requires Foundry and Solidity ^0.8.30.

git clone <repo>
cd contracts
forge install

Development

All 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 + size

Build 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).

Tests & Fixtures

Test vectors live in test/fixtures/ (see its README):

  • asset_registry.json — asset generators for MASP deploy
  • proof_deposit_batch_n1.json — sample batched tree-update proof
  • proof_transfer.json — sample transfer proof
  • tree.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.

Deployment

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)
  • DeployTest deploys Verifier, TreeUpdateBatchVerifier, MASP, Permit2 (etched at its canonical address), MockWETH9, and MockERC20s seeded from asset_registry.json, signing with anvil dev key #0.
  • DeployTestSwap reads MASP, PERMIT2, TOKEN_1..3 from env — export them via eval "$(just deploy-anvil | e2e/deploy/extract-addresses.sh)".
  • Deploy reads script/config/mainnet.json (override with MAINNET_CONFIG); DeploySwap reads script/config/mainnet.swap.json (override with SWAP_CONFIG). Arbitrum configs live alongside; *.example.json files document the schema.
  • just dry-run-mainnet / just dry-run-swap simulate without broadcasting.

Shared logic lives in script/base/BaseDeploy.s.sol and script/base/BaseSwapDeploy.s.sol.

Gas

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
  • submitIntent covers the per-deposit hot path: Permit2 permitWitnessTransferFrom (~75 k), aux validation, a 2-slot EscrowedIntent write, and the IntentEscrowed log (~1.2 KB ciphertext). No SNARK verification on this leg.
  • flushBatch amortizes one batched TreeUpdateBatchVerifier.verifyProof (195 026) plus the Merkle root advance across up to MAX_N_BATCH intents; each intent's submit-time digest is rebuilt from calldata and checked against a single storage slot.
  • cancelIntent refunds escrow after cancelDelay, verifying the caller-supplied digest preimage against escrowed[id].digest.
  • Both Groth16 verifiers cost 195 026 per verifyProof call 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.

Custom Errors

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

Dependencies

  • @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.

License

TBD

About

Multi-Asset Shielded Pool (MASP) smart contracts — private transfers and withdrawals via Groth16 SNARKs, Permit2 escrow deposits, and batched Merkle tree updates

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages