Skip to main content

ADR-022: Freeze and Seize

StatusImplemented
Date2026-05-04

Context

EMI compliance obligations — court orders, sanctions hits, AML investigations — require the issuer to (a) immobilize specific user accounts and (b) move funds from those accounts to a designated authority. The current contracts have no such entry points. The role model from ADR-018 only covers issuance, and the AML Enforcement sketch lists Freeze and Seize as required actions but does not specify their on-chain shape.

Two design constraints make this non-trivial:

  1. Frozen state must be enforced at every credit and every debit path across both layers. The encrypted layer's _creditSlot chokepoint already centralizes inbound credit, but outbound paths and the public ERC-20 surface need explicit checks too.
  2. Seizing from an encrypted balance has no public amount on-chain. The contract cannot verify balance >= amount without the source ESK, which the issuer does not hold. Forcing the SEIZER to prove this in zero knowledge would either require the source's cooperation (defeats the purpose) or require the auditor key inside the circuit (couples seize to the Threshold Compliance protocol on every call).

This ADR specifies Freeze and Seize. Blacklist (full deactivation, item three from the AML sketch) is out of scope.

Proposal

Roles

Two new owner-maintained role sets, disjoint from owner, minters, and the burner authorization tables:

mapping(address => bool) public freezers;
mapping(address => bool) public seizers;

Admin functions, all onlyOwner with immediate effect: setFreezer(address, bool), setSeizer(address, bool).

Following ADR-021, role sets and the freeze registry below live on Hub when the contract split lands, so a freeze applies uniformly across every token instance.

Freeze registry

Two mappings, both write-gated to freezers[msg.sender]:

mapping(address => bool) public frozenAddresses;
mapping(CompressedPoint => bool) public frozenEpks;

Entry points: freezeAddress(address) / unfreezeAddress(address) and freezeEpk(CompressedPoint) / unfreezeEpk(CompressedPoint).

A frozen account is blocked on both directions — outbound and inbound — across both layers. Bidirectional blocking is deliberately stronger than the AML business-case sketch (which blocks outgoing only). Pinning both sides at freeze time gives investigators a stable balance snapshot: any subsequent TRC-driven decryption reflects the balance at the moment of freeze, with no after-the-fact incoming credits to disentangle.

Seize

Two entry points, both gated by seizers[msg.sender] and both requiring the source to be frozen:

function seizePublic(address from, address to, uint256 amount);
function seizeEncrypted(CompressedPoint epk, address to, uint256 amount);

to is always a public EVM address. amount is always public. Both emit a Seize* event.

Public source. seizePublic is a forced ERC-20 transfer: it debits from and credits to via the internal transfer path, bypassing the freeze check that would normally block both ends. Insufficient balance reverts via OpenZeppelin's ERC20InsufficientBalance — Hub does not duplicate the check.

Encrypted source. seizeEncrypted constructs Enc(amount, epk, 0) = (O, amount · G) on-chain and homomorphically subtracts it from encryptedBalances[epk], then mints amount ERC-20 to to. With r = 0 the ciphertext is deterministic, so no randomness needs to be supplied or proven. Pending state is folded in first via the standard pending-merge before subtraction, so a single call drains both encryptedBalances and pendingBalances.

The encrypted total-supply counter is not touched on either path: seize moves tokens between or within layers, never changes issued supply, and is symmetric to encryptedToPublicTransfer in that respect.

No ZK proof, no controller signature: authorization is seizers[msg.sender], and the SEIZER does not hold the source ESK.

Why no in-circuit balance check. The contract cannot verify oldBalance >= amount without the source ESK. The SEIZER is trusted — symmetric to the trust placed in encryptedMint's caller — and is expected to learn the source's balance via the Threshold Compliance revoking workflow before issuing the seize. The freeze prerequisite makes this reliable: once frozen, the balance cannot change, so the SEIZER's TRC-derived value remains accurate up to the moment of seize. If a SEIZER subtracts more than the true balance the source ciphertext becomes a point that no longer decrypts to a value in [0, 2^64); this is detectable but not automatically rejected. Issuer back-office processes are responsible for never over-seizing.

Events

EventPurpose
AddressFrozen(address) / AddressUnfrozen(address)Freeze registry changes
EpkFrozen(CompressedPoint) / EpkUnfrozen(CompressedPoint)Freeze registry changes
PublicSeized(address indexed from, address indexed to, uint256 amount)Public seize
EncryptedSeized(CompressedPoint indexed compressedEpk, address indexed to, uint256 amount)Encrypted seize
FreezerUpdated, SeizerUpdatedAdmin role changes