Skip to main content
Version: 0.3.0

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 mappingKeyGuards
Hub.freezersEVM addressfreezeAddress, unfreezeAddress, freezeEpk, unfreezeEpk
Hub.seizersEVM addressseizePublic, 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) calls Hub.frozenEpks(epk). Every encrypted credit (encryptedTransfer recipient, publicToEncryptedTransfer, autoEncrypt routing, executeEncryptedMint) goes through this helper.
  • Outbound: encryptedTransfer and encryptedToPublicTransfer each call Hub.frozenEpks(senderEpk) near the top of the function. Hub.encryptedBurn checks 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 pointEBEMT executorSource 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

EventPurpose
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.