Skip to main content
MIP410 is the protocol-deployed shielded pool at 0x0000000000000000000000000000000000000410. This page describes its current contract interface. The key holder constructs notes and wallet authorization signatures. It may be the customer’s wallet or the custodial system of the customer’s VASP. For a shielded payment, the sender VASP prepares initiatePayment. The recipient VASP prepares acceptPayment or rejectPayment. Each call carries a payment proof and a policy-wrapper proof. Any public account may submit a prepared transaction and pay gas. See Who submits. The pool maintains its own VASP registry and sanctions registry. The authority admits VASPs and assets, sets circuit verification keys, and can pause value movement. A fresh pool starts with empty registries, no admitted assets, and no configured circuit IDs. The authority configures them before the first shield. Solidity identifiers retain Fi and fi in their names. In this reference, those identifiers refer to VASPs. Use the matching ABI and client package for calldata and return encoding.

Core types

Expired is never stored. A hold whose deadline has passed remains Held in storage, and holdOf synthesizes Expired when reporting it. acceptPayment and rejectPayment select the outcome. The circuit uses Accept = 0 and Reject = 1, as listed under Proof bindings. Both inputLockIds and nullifiers must carry exactly MAX_INPUTS = 2 entries; a wrong length reverts WrongInputArity. A zero entry is a dummy slot. A payment spending one note supplies its real value first and zero second. inputLockIds identify the inputs a hold reserves. They are distinct from nullifiers, which the contract records only when a payment is accepted. An unshield presents its input lock IDs so the pool can refuse any input that a hold has reserved or that an accepted payment has spent. Acceptance keeps the input locks permanently set. Only rejectPayment and reclaimExpired release them. FiRecord is one leaf’s preimage in the VASP tree: the institution’s identifier, the hash of its policy verification key, and its Grumpkin messaging key as two coordinates. A vacant slot reads as all zeros. Point carries a Grumpkin point — a messaging key or a Schnorr nonce — as raw coordinates. SanctionsLeaf is one entry of the sorted sanctioned set: its key and the next greater key, zero for the greatest. Circuit indexes the circuit-ID table; it travels as uint8.

Root windows

Each request names the roots its proofs use. The applicable roots differ by transaction type, as listed in Proof bindings. The commitment tree accepts every nonempty root it has had. Appending notes does not invalidate a membership proof against an earlier root. Input locks and nullifiers prevent the same note from being spent twice. The VASP registry accepts its current root and, after an admission, the root immediately before that admission. Revoking a VASP or updating its record clears the previous root, so the change takes effect immediately. The sanctions registry accepts only its current root. Zero is never accepted by either registry. A proof against a retired registry root must be rebuilt with the accepted registry state. If the required VASP admission or sanctions non-membership can no longer be proven, a held payment can be reclaimed after its deadline. Use isKnownRegistrationRoot(root) to check registration-root acceptance, and compare the sanctions root with sanctionsRoot(). previousRegistrationRoot() reports the registration root retained after an admission, or zero when there is no previous accepted root.

Pool and hold state

A proof may cite any historical nonempty commitment root. The empty tree root is not accepted as a spend root. Passing the deadline makes a hold reclaimable but releases nothing on its own; only the reclaimExpired call does that.

Registry state

The VASP tree is a positional Merkle tree of depth 10: 1,024 slots, one admitted institution per occupied slot, vacant slots hashing to the empty leaf. The sanctioned set is a sorted indexed tree of depth 20 whose entries form a linked list in key order, position 0 being the start-of-chain leaf. Both live in the pool’s storage, so anyone rebuilds either tree — and any inclusion or non-membership path — from the getters alone; the rebuilt root must equal the pool’s, otherwise the reader has stale or partial data. Fetch fiRecords(0, 1024) and sanctionsLeaves(0, sanctionsCount()) to rebuild both trees in two calls. Registry events can trigger a refresh of these records.

Administration state

Shield

The customer VASP prepares shield. The shield proof verifies the key holder’s Schnorr authorization and binds the public asset and amount received by the pool to request.noteCommitment. The diversified address, spending key, and note secret stay private. request.asset must be one the authority has admitted, otherwise the call reverts AssetNotAdmitted before anything is pulled. request.sanctionsRoot names the sanctions root the proof’s non-membership opening used; see Root windows. depositAuthorization authorizes the public MIP20 transfer from request.depositor into one note, encoded as:
The pool decodes that and calls the asset’s ERC-3009 receiveWithAuthorization with itself as payee and request.noteCommitment as the nonce. The depositor therefore signs a ReceiveWithAuthorization whose to is the pool and whose nonce is the commitment of the note being funded. The token’s EIP-712 domain binds the chain and token. The payee and single-use nonce bind the authorization to funding this note at this pool. The shield proof also verifies the note owner’s Schnorr authorization; this is separate from the depositor’s ERC-3009 signature. The pool measures its MIP20 balance before and after the pull and requires an increase of exactly request.amount, otherwise it reverts DepositDeltaMismatch. request.shieldTimestamp enters the note commitment. It must satisfy shieldTimestamp <= block.timestamp and block.timestamp - shieldTimestamp <= 3600 seconds. Successful execution updates backingOf(request.asset) and appends the note commitment. Applications wait for the containing block to become final before using the note. While the pool is paused, shield reverts Paused.

Initiate a shielded payment

The sender VASP prepares initiatePayment. request.paymentEffectHash commits the payment effect without publishing the recipient or change note commitment before the payment is accepted, and it is the returned holdId — holds are keyed by their own effect hash, written once and never deleted, so the same effect can never open a second hold. request.deadline must satisfy block.timestamp < deadline and deadline <= block.timestamp + maxHoldDuration(), reverting DeadlinePast or DeadlineExceedsCap respectively. request.nonce is forwarded to the proof alone; the pool neither stores it nor keys anything on it. request.payloadCommitment commits to the payment’s payload: its identifiers and, when present, its travel-rule record. It must be a canonical field element and nonzero, reverting NonCanonicalField or ZeroPayloadCommitment, and the pool stores it on the hold. request.registrationRoot and request.sanctionsRoot name the anchors the two proofs opened; see Root windows. Two proofs are required. senderProof is the initiation statement, binding:
  • the chain and the pool;
  • the commitment root the inputs are proven against;
  • both input lock IDs;
  • the payment effect hash and the recipient VASP binding commitment;
  • the nonce and deadline;
  • the payload commitment, recomputed from the payload’s plaintext; and
  • the sanctions root — a sanctions non-membership proof.
The initiation circuit also verifies the spending-key signature, input-note membership and ownership, amount bounds, and value conservation. The nullifiers and output note commitments stay private inside the payment effect hash until acceptance. policyProof is the sender VASP’s policy-wrapper proof. The wrapper verifies the private policy proof under the verification-key hash in that VASP’s registry leaf. It exposes the same chain, pool, payment effect hash, payload commitment, commitment root, and recipient VASP binding commitment at phase 0. It proves the sender VASP’s membership against request.registrationRoot and verifies that VASP’s signature under its registered messaging key. The payload itself travels off chain, sealed to the recipient VASP. The pool stores only its commitment, and both resolution proofs carry the stored value. A successful initiation creates a hold, fixes its payment effect and deadline, and locks every real input. Nullifiers are not consumed, and neither the recipient nor the change note commitment is appended. While the pool is paused, initiatePayment reverts Paused.

Resolve a shielded payment

The recipient VASP resolves a finalized, live hold addressed to it by calling one of the two entry points. It prepares recipientProof, binding the decision to the hold’s payment effect, and policyProof, its policy-wrapper proof. Any account may then submit the prepared pair. roots names the registration and sanctions roots the two proofs opened — a resolution has no request struct of its own, so the pair rides beside the hold ID; see Root windows. Both proofs carry the payload commitment stored on the hold; neither entry point takes it as a parameter. Resolution requires block.timestamp <= deadline. acceptPayment takes the nullifiers plus the recipient and change note commitments that paymentEffectHash committed to. The pool recomputes the effect hash from the four supplied words and requires it to equal holdId, reverting EffectMismatch otherwise. Given a match, the pool records the nullifiers, appends the recipient note commitment and any nonzero change note commitment, and marks the hold Accepted. Its input locks stay set permanently, preventing the spent inputs from entering another hold or unshield. rejectPayment takes no resolution effect. The pool supplies four zero values to the resolution proof, so no nullifiers or proposed note commitments are published. It releases the input locks and marks the hold Rejected, recording no nullifiers and appending no commitments. Policy-wrapper phases differ between the two: 1 for accept, 2 for reject. At both phases the wrapper opens the recipient VASP’s leaf against roots.registrationRoot and requires the leaf’s institution to be the one the hold’s recipient VASP binding commitment names. An invalid, stale, mismatched, or unauthorized resolution leaves the hold unchanged. Applications update payment status only after successful execution and block finality. While the pool is paused, both entry points revert Paused.

Reclaim an expired hold

reclaimExpired is valid only after the hold deadline. Successful execution moves the stored state from Held — which holdOf has been reporting as Expired — to Reclaimed, and releases the input locks. Nullifiers are not consumed and no note commitment is appended. The Accepted, Rejected, and Reclaimed terminal states are mutually exclusive, and none of them can be left once entered. reclaimExpired carries no proof and reads only public hold state, so the pool accepts it from any account once the deadline has passed. The sender VASP normally reclaims the hold and pays the gas, but another account may call reclaimExpired with the same result. Reclaim is never paused: a hold whose resolution the pause prevented can still be released after its deadline.

Unshield

The customer VASP prepares unshield. The call requires one unshield proof and no policy-wrapper proof. It binds the note spend, the chain and pool, the asset, amount and destination, both nullifiers, both input lock IDs, the change note commitment, and the sanctions root named by request.sanctionsRoot. The signature under the spending key authorizes the destination. MIP410 has no customer or destination-registration check; any destination restrictions are the customer VASP’s offchain controls and the public token’s transfer policy. Revoking an asset’s admission stops new shields while allowing existing backing to be unshielded. Execution requires every entry of inputLockIds to be unlocked, so a note reserved by a live hold — or permanently locked by an accepted payment — cannot be unshielded. The pool follows strict checks-effects-interactions: it records the nullifiers, appends any nonzero change note commitment, and reduces backingOf(request.asset) before transferring to request.destination. After the transfer it asserts that its own MIP20 balance still covers the asset’s remaining backing. A failed transfer, or a broken solvency assertion, rolls back the complete operation. While the pool is paused, unshield reverts Paused.

Bridge

Value proven onto Metal from a foreign chain enters the pool as a note, with no MIP20 balance and no shield call. Only bridgeGateway() may call either function; any other caller, or any caller while no gateway is set, reverts NotBridgeGateway. The gateway attests the deposit: its id, asset and amount. The note is never trusted: the bridgeMint proof shows that request.noteCommitment opens to exactly that amount and asset for an unsanctioned owner. Each depositId mints once; a second mint reverts BridgeDepositAlreadyMinted. Bridged value is accounted in bridgeLiabilityOf(asset), never in backingOf(asset), because its reserves sit on the foreign chain. The two paths never share an asset: bridgeMint reverts BridgeAssetShieldable for an asset admitted for shield or still backing shielded notes, and admitAsset reverts AssetBridged for an asset with bridge liability outstanding. Bridged notes pay and resolve like any other note. bridgeWithdraw is the exit toward the foreign chain. It spends notes exactly as unshield does — the same nullifier set, input locks checked and change appended — but moves no tokens. Instead of a destination it binds an opaque routeId and a destCommitment that the foreign vault opens at payout, so the pool never learns the payout address. It reduces bridgeLiabilityOf(request.asset), reverting InsufficientBridgeLiability when the liability cannot cover the amount, and returns the withdrawalId the gateway routes outward. While the pool is paused, both functions revert Paused.

Payment requests

A shielded payment starts with a request the recipient’s VASP issues off chain and the sender’s wallet verifies before anything is proven. The request never reaches the pool; it is listed here because the sender’s check reads the VASP tree through the getters above. The sender’s wallet reads fiRecord(index), checks that its fiId is the request’s, verifies the signature under the record’s messaging key, and refuses a request whose expires_at has passed at the chain’s current block timestamp. A request signed under a key the VASP has since rotated away fails that check, so the sender fetches the record fresh rather than caching it. Only then does it build the initiation proof. The payment request signature and expiry are verified offchain. They are not verified by the pool or initiation circuit. A wrong VASP binding can leave the hold unresolved until reclaim.

Proof system

Commitment and field rules

The note commitment is:
P, bitmap_0, bitmap_1, and depth hash as zero. asset_id encodes the 160-bit MIP20 token address directly as a field element. Proof-bound field elements use canonical encodings below the BN254 field modulus. The pool checks the calldata words it uses as native field elements and reverts NonCanonicalField for out-of-range values.

Proof bindings

The generated circuit package is the source for serialized public-input order. At the contract boundary, each proof is bound to the following data: The roots in every row are the values the request named, checked against the windows before the proof is verified. Each VASP’s policy verification key hash sits in its leaf of the VASP tree beside its messaging key, so the wrapper proves that the policy it verified is the one the leaf carries and that the VASP signed for this payment. Each proof is verified against the verification key the circuit-ID table names for its circuit. A proof under any other key — including a key the table pointed at before a rotation — reverts InvalidProof carrying the circuit ID it was checked against.

Authority, registry, and VASP controls

Every write below is restricted to authority() unless noted, emits an event, and stays available while the pool is paused. Registry writes update their roots according to the root windows.

The VASP tree

admitFi reverts FiSlotOccupied at a taken slot, FiIdInUse for an identifier already admitted elsewhere, InvalidFiRecord for a zero identifier or VK hash, and InvalidMessagingKey for a key that is the identity, off the curve, or non-canonical. revokeFi and updateFi revert FiSlotVacant at an empty slot. Messaging keys are bound to one VASP identifier. admitFi and updateFi revert MessagingKeyInUse if another VASP holds or has held the supplied key. A VASP can reuse its own earlier key. updateFi verifies the Schnorr signature (r, s) under the slot’s current messaging key over Poseidon2(DOMAIN_FI_UPDATE_AUTH, chainId, pool, registrationRoot, index, nonce, fiId, vkHash, mpk.x, mpk.y), where registrationRoot is the current anchor and nonce is fiUpdateNonce(index). Each update increments the nonce, and revocation does not reset it. Binding the root means a signature over a stale tree cannot be replayed after any other write; a signature that does not verify reverts InvalidFiSignature. Rotating the policy and rotating the key are the same write.

The sanctioned set

lowIndex names the entry that brackets the new key: low.key < affilId < low.nextKey, or low.nextKey == 0 when the new key is the greatest. A wrong bracket — including an affilId already present — reverts SanctionsBracketMismatch; a position at or past the count reverts SanctionsIndexOutOfRange; a zero key reverts InvalidAffilId. Read sanctionsLeaves to find the bracket before calling. Lifting a sanction is a reset without the identifier. A reset that is not strictly ascending reverts SanctionsListUnsorted. The rebuilt set starts a new generation, so positions and paths from the old generation no longer apply.

The circuit-ID table

A verification key rotates without a redeploy: register the new key in the circuit registry, then point the table at its ID. Proofs under the old key are refused from that block on. There is no window, because a key rotation is a deliberate upgrade the authority sequences with the institutions.

Authority, pause, assets, and the hold ceiling

The current authority retains its powers until the pending account calls acceptAuthority. A zero pending address cancels the transfer. While paused, reclaimExpired and registry and administrative writes remain available. Asset revocation affects shielding only. Policy commitments and verifier metadata do not publish private policy inputs. Submitter accounts and their gas payments remain public, so a VASP that submits its own transactions may be identifiable at the transaction layer.

Gas

The hidden payment amount is conserved independently of transaction gas. Whichever account submits a transaction pays ordinary Metal gas in a MIP20 gas token. The submitter need not be the VASP that prepared the transaction.

Events and errors

Use the Event Catalog to index note commitments, nullifiers, hold transitions, and registry writes. Use Error Codes to distinguish proof failures, input lock conflicts, terminal holds, registry failures, and administration errors. Reverts from the MIP20 token or malformed ABI data can carry other revert data.

MIP410 shielding

Understand how public MIP20 backing enters the shielded pool.

Shielded payment guide

Follow the shield, initiate, resolve, and unshield workflow.

Constants and limits

Check tree, amount, input, and hold bounds.

Key derivation

Review the spending key, viewing keys, the diversified address, and the VASP messaging key.