Freeze and Seize
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 Hub exposes both as first-class entry points. Full blacklist / deactivation is out of scope. For the automated, oracle-driven complement to manual freeze, see Sanctions Screening.
The freeze registry, the freezer/seizer role sets, and the seize entry points all live on the Hub — a single freeze applies uniformly across every EBEMT in the deployment. Each EBEMT only enforces the checks (in _update for the public layer, _creditSlot and the outbound encrypted-balance entry points for the encrypted layer) and exposes onlyHub seize executors.
Roles
Two owner-maintained role sets on the Hub, disjoint from owner, minters, and the burner authorization tables:
| Role mapping | Key | Guards |
|---|---|---|
Hub.freezers | EVM address | freezeAddress, unfreezeAddress, freezeEpk, unfreezeEpk |
Hub.seizers | EVM address | seizePublic, seizeEncrypted |
owner is NOT implicitly either — a dedicated operational address (typically a Safe multisig) must be enrolled. Admin functions (all onlyOwner on the Hub, revocation immediate):
setFreezer(address, bool)setSeizer(address, bool)
The two roles are deliberately split: a freezer can immobilize an account (reversible) but cannot move its funds; a seizer can move funds (irreversible) but only from accounts already frozen. Compromising one role alone is insufficient to extract funds — the seizer needs the freezer's coordination.
Freeze registry
Two Hub mappings, both write-gated to freezers[msg.sender]:
mapping(address => bool) public frozenAddresses; // on Hub
mapping(CompressedPoint => bool) public frozenEpks; // on Hub
Entry points (all on the Hub):
freezeAddress(address) / unfreezeAddress(address)freezeEpk(CompressedPoint) / unfreezeEpk(CompressedPoint)
A frozen account is blocked bidirectionally — both outbound and inbound — across both layers and across every EBEMT in the deployment. 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 threshold-compliance decryption reflects the balance at the moment of freeze, with no after-the-fact incoming credits to disentangle.
Enforcement points
Each EBEMT consults the Hub's freeze registry on every balance-moving operation:
Public layer — every credit and debit goes through ERC-20 _update, which calls Hub.frozenAddresses(from) and Hub.frozenAddresses(to) (skipping the zero address). This single chokepoint covers transfer, transferFrom, publicMint, publicBurn, publicToEncryptedTransfer (the burn side), encryptedToPublicTransfer (the credit side), and the autoEncrypt redirect.
Encrypted layer — checks land at two points:
- Inbound:
_creditSlot(epk)callsHub.frozenEpks(epk). Every encrypted credit (encryptedTransfer recipient, publicToEncryptedTransfer, autoEncrypt routing,executeEncryptedMint) goes through this helper. - Outbound:
encryptedTransferandencryptedToPublicTransfereach callHub.frozenEpks(senderEpk)near the top of the function.Hub.encryptedBurnchecks the same on its own (the burn flow is Hub-driven).
_clearPending (same-EPK pending → encrypted merge) does not freeze-check; the EPK identity is preserved, so the freeze invariant is unaffected.
Seize
Two entry points on the Hub, both gated by seizers[msg.sender] and both requiring the source to be already frozen. The Hub does authorization, validation, and the freeze-prerequisite check; the EBEMT executor (onlyHub) does the state mutation.
| Hub entry point | EBEMT executor | Source prerequisite |
|---|---|---|
seizePublic(address ebemt, address from, address to, uint256 amount) | executeSeizePublic(from, to, amount) | Hub.frozenAddresses(from) |
seizeEncrypted(address ebemt, Point epk, address to, uint256 amount) | executeSeizeEncrypted(compressEpk(epk), to, amount) | Hub.frozenEpks(compressEpk(epk)) |
to is always a public EVM address. amount is always public (in the clear, both on stack and in events). Both emit a *Seized event from the EBEMT (state mutation lives there). executeSeizeEncrypted additionally emits EncryptedToPublicTransfer — seize is functionally a layer-crossing transfer, so indexers tracking that flow pick it up without a special-case branch.
seizePublic
A forced ERC-20 transfer: executeSeizePublic debits from and credits to via super._update, bypassing the freeze check on both ends and the autoEncrypt override (the funds always land in to's public balance regardless of to's autoEncrypt setting). Insufficient balance is caught by OpenZeppelin's ERC20Upgradeable._update (ERC20InsufficientBalance(from, fromBalance, amount)); Hub does not duplicate the check.
seizeEncrypted
executeSeizeEncrypted constructs Enc(amount, epk, 0) = (O, amount·G) on-chain — a deterministic ciphertext with r = 0, so no randomness needs to be supplied or proven. Folds any pending balance into encryptedBalances[epk] first (via the standard pending-merge), then homomorphically subtracts amount·G from the C component. Mints amount ERC-20 to to, again via super._update (bypassing destination-side freeze and autoEncrypt).
Total supply counter
The encrypted total-supply counter is not touched on either path: seize moves tokens between or within layers but never changes issued supply. This is symmetric to encryptedToPublicTransfer, which is also layer-crossing and counter-neutral.
No in-circuit balance check on encrypted seize
The contract cannot verify oldBalance >= amount without the source ESK, which the seizer 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).
The seizer is trusted — symmetric to the trust placed in encryptedMint's caller — and is expected to learn the source's plaintext balance via the Threshold Compliance revoking workflow before issuing the seize call. The freeze prerequisite makes this reliable: once frozen, the balance cannot change, so the threshold-compliance-derived value remains accurate up to the moment of seize. Over-seizing produces a ciphertext that no longer decrypts to a value in [0, 2^64) — detectable off-chain but not auto-rejected on-chain. Issuer back-office processes are responsible for never over-seizing.
Events
| Event | Purpose |
|---|---|
AddressFrozen(address) / AddressUnfrozen(address) | Freeze registry changes (public layer) |
EpkFrozen(CompressedPoint) / EpkUnfrozen(CompressedPoint) | Freeze registry changes (encrypted layer) |
PublicSeized(address indexed from, address indexed to, uint256 amount) | Public seize |
EncryptedSeized(CompressedPoint indexed compressedEpk, address indexed to, uint256 amount) | Encrypted seize |
FreezerUpdated(address indexed freezer, bool enabled) | Admin role change |
SeizerUpdated(address indexed seizer, bool enabled) | Admin role change |
Security model
Seizer compromise is bounded by the freeze prerequisite. A compromised seizer cannot extract funds from any account the freezer hasn't already enrolled; the two roles must be co-compromised (or jointly held) to drain a target. Operationally this means the freezer and seizer keys belong on different multisigs.
Freezer compromise is reversible. A rogue freezer can DoS arbitrary accounts (block both directions), but cannot move funds. Recovery is unfreezeAddress / unfreezeEpk from a clean freezer key, plus setFreezer(rogue, false) from owner.
Frozen state is bidirectional. Freeze blocks both outgoing operations and inbound credits. The inbound block is what gives investigators a stable balance snapshot to decrypt under threshold compliance — without it, a frozen target could continue receiving credits between freeze and decryption, and the on-chain ciphertext would diverge from any computed plaintext.