Skip to main content

ADR-023: Card-Issuer Vault for EBEMT

StatusProposed
Date2026-05-08

Context

The EBHub controller for an EPK is a single address that authorizes every controller-signed token-domain operation: encrypted transfers, encrypted-to-public transfers, pending-balance toggles. With a plain EOA controller, that key has to be online for every transfer.

The driving use case is card-issuer (CIS) operation: a CIS operates payments on behalf of a user, but must be constrained to debit only into a fixed settlement destination — never to arbitrary recipients.

Two EBEMT-specific constraints shape the design:

  • Encrypted balances are opaque. The vault cannot read grossBalance to enforce ERC-20-style "withdrawal can't exceed balance minus pending" invariants. Any pending-withdrawal protection has to be expressed in encrypted-balance terms.
  • ERC-1271 only sees an opaque digest. A "CIS operator can only sign transfers to settlement" constraint cannot be enforced via signature scoping. We move the constraint to the Solidity call boundary instead, by giving EBEMT an msg.sender == controllerOf(senderEpk) authorization path. Vaults then expose narrow methods that bake the recipient into the call.

Architecture

Two contracts.

CardIssuerHub (deployment-wide, one per CIS)

UUPS-upgradeable. Combines four responsibilities:

  • Config: settlement EPK, settlement public address, withdraw delay, debits-paused flag, vault implementation Beacon address. Vaults read these values live, so admin updates take effect across every vault immediately.
  • Roles: AccessControl registry — DEFAULT_ADMIN_ROLE for governance, CIS_ROLE for CIS operators (any holder may debit any vault — there is no per-vault CIS operator assignment), and PAUSER_ROLE for debitsPaused.
  • Factory + registry: createVault(...) deploys a BeaconProxy, atomically registers epk on EBHub with the new vault as its controller, and indexes the vault (vaultOf[owner], isVault). One vault per owner; the EPK is bound at vault creation. The whole createVault is atomic — if any step reverts the entire deployment unwinds.
  • Event sink: vaults call record* to surface lifecycle events (Debited, WithdrawRequested, EbemtEnabled, etc.) through the hub's stable proxy address. Off-chain indexers subscribe to one address.

Per-owner vault (CardIssuerEpkVault)

One vault per owner. The vault is bound to a single EPK at creation, but mediates transfers on that EPK across multiple EBEMTs that share the same hub — caller specifies ebemt per call. Each EBEMT must be explicitly enabled by the owner before the CIS operator can debit it on this vault (see "EBEMT enable/disable" below).

Deployed as a BeaconProxy so admin-driven security upgrades affect every vault atomically. Settlement EPK, settlement public address, and withdraw delay are read live from the hub on every call — admin-side updates propagate immediately to every existing vault.

The vault is registered as the EBHub controller of epk. All token-domain operations on epk go through the vault: there is no signature path that bypasses it, because the vault does not implement ERC-1271 (see "EBHub binding" below).

Authority on the vault:

  • Owner — the user. Declares and executes withdrawals, manages the EBEMT enable/disable lifecycle, transfers ownership (transferOwnership is overridden to atomically update the hub's vaultOf mapping).
  • CIS Operator — any address holding CIS_ROLE on the hub. Debits epk to either settlementEpk (encrypted) or settlementPublicAddress (public). No per-vault CIS operator assignment; CIS-side hot-key rotation is handled by granting/revoking CIS_ROLE deployment-wide.

EBEMT extension

EBEMT must accept encryptedTransfer and encryptedToPublicTransfer from msg.sender == controllerOf(senderEpk) directly, bypassing EIP-712 signature validation. This is what lets the vault originate transfers without a signature dance. The signature-bearing overloads are unchanged for non-vault controllers.

Pending withdrawal: declaration, not balance carve-out

Pending withdrawals are pure intent records — no funds are physically locked during the delay window. The CIS operator's debit authority is unaffected during a pending withdraw; they may continue to debit any amount, and the owner accepts that whatever the CIS operator drains before executeWithdraw is gone.

  • requestWithdraw(ebemt, transferAmount) stores the transferAmount ciphertext and block.timestamp for that EBEMT. No proof, no balance movement.
  • After vaultExecutionDelay elapses, executeWithdraw(ebemt, ...) performs the actual transfer. The vault binds the executed transferAmount to the declared one; the recipient EPK is owner-chosen at execute time.
  • cancelWithdraw(ebemt) clears the declaration.

Pending state is per-EBEMT — the owner can have one in-flight declaration per token simultaneously.

An earlier draft of this design carved out a secondary EPK to physically hold pending-withdraw amounts (requestWithdraw would transfer to it; the CIS operator would have no debit authority over it). We dropped fund-locking in favor of the declaration model: simpler contract surface, only one EPK to register and reason about, and the additional protection of fund-locking matters only for the narrow window between request and execute. The declaration is a public commitment of intent — off-chain monitoring sees the declared ciphertext via the WithdrawRequested event and can react if a CIS operator starts draining ahead of execution. See "Possible improvements" below for the secondary-EPK option as a future extension.

EBEMT enable/disable

Owner explicitly opts each EBEMT into CIS operator authority on this vault:

  • enableEbemt(ebemt) — instant. Until called, the CIS operator cannot debit on that EBEMT through this vault, and the owner cannot run the withdraw flow on it.
  • requestDisableEbemt(ebemt) → wait vaultExecutionDelayexecuteDisableEbemt(ebemt) — disabling is timelocked. During the delay window the CIS operator retains debit authority, giving them a chance to wrap up legitimate debits before being cut off. cancelDisableEbemt(ebemt) aborts a pending disable.

The enable/disable asymmetry (instant on, delayed off) reflects the trust direction: enabling grants authority owner-side and is risk-free; disabling revokes authority and warrants the same notice window the CIS operator gets for any owner-initiated state change.

Trust model

What the CIS operator (any CIS_ROLE holder) can do:

  • Debit any enabled EBEMT on any vault to either settlementEpk (encrypted) or settlementPublicAddress (public). Recipient is enforced in Solidity, not via signature scoping.
  • Subject to debitsPaused (PAUSER-controlled, deployment-wide) and per-vault ebemtEnabled[ebemt].

What the CIS operator cannot do:

  • Initiate a transfer to any other recipient.
  • Touch the withdraw declaration or the timelock.
  • Toggle ebemtEnabled, transfer ownership, or call any management method on a vault.
  • Authorize anything via EBHub (no ERC-1271).

What the owner can do:

  • Declare and execute withdrawals (subject to delay).
  • Cancel a pending declaration.
  • Enable an EBEMT (instant) or request and execute its disable (timelocked).
  • Transfer vault ownership; the override atomically updates the hub's vaultOf mapping.

What the owner cannot do:

  • Bypass the timelock (for withdraws or for EBEMT-disable).
  • Make the CIS operator unable to drain to settlement during a delay window.
  • Unilaterally remove the CIS operator role; CIS_ROLE is a hub-level role, not a per-vault assignment. The owner can only block authority by disabling each EBEMT individually.

What DEFAULT_ADMIN_ROLE can do:

  • Upgrade CardIssuerHub (UUPS) and the vault implementation (Beacon-driven, atomic across all vaults).
  • Grant/revoke CIS_ROLE, PAUSER_ROLE.
  • Update settlement values and withdraw delay. These take effect live on every existing vault. Admin can redirect future debits and shorten or lengthen pending-withdraw windows in flight.

The trust posture: admin already has Beacon upgrade authority, so admin-driven settlement and delay changes are an in-character extension of admin power rather than a new trust assumption. The CIS operator retains drain capability to settlement at all times; daily caps and rate limits are off-chain (encrypted amounts can't be capped trustlessly). Withdrawals through the timelock give the owner a delayed-but-reliable exit; the cost they accept is whatever the CIS operator drains during the delay window.

EBHub binding

EPKs registered with a CardIssuerEpkVault as their controller cannot rotate that controller in EBHub. The vault does not implement ERC-1271, so EBHub's changeController flow — which validates a signature against the registered controller — has nothing to call into and reverts. The EPK is permanently bound to the vault. The owner's exit is executeWithdraw to a different EPK they fully control.

This is intentional: it removes a class of "hostile vault upgrade" concerns and means the only on-chain authority over epk is the vault's logic.

Decisions baked in

  • Vault ownership: OZ OwnableUpgradeable. transferOwnership is overridden to atomically update the hub's vaultOf mapping; both the on-vault role and the hub-side index move together.
  • EBHub controller rotation is permanently locked for vault-controlled EPKs; the only escape is executeWithdraw to a fresh EPK.
  • CIS operator model: hub-wide CIS_ROLE. Any holder can debit any enabled EBEMT on any vault. No per-vault CIS operator assignment; CIS hot-key rotation is a role grant on the hub.
  • Pause: only debitsPaused (deployment-wide, vault reads at debit boundary). Withdrawals never pause.
  • Settlement destinations: both encrypted (settlementEpk) and public (settlementPublicAddress) supported from v1, read live from the hub.
  • One vault per owner. A single vault serves the owner across every EBEMT they enable on it.
  • EBEMT enable/disable: instant enable, timelocked disable.
  • Vault deployment: permissionless. EBHub's registerEpk requires an ESK proof, so a malicious deployment can't capture funds.

Possible improvements

  • The CIS operator could no longer drain pending amounts during the delay window.
    • A. Fund-locking pending withdraws via a secondary EPK. The vault could be bound to two EPKs at creation: a primary EPK (CIS operator-debitable) and a secondary EPK that the CIS operator has no debit authority over. requestWithdraw would transfer the declared amount from primary to secondary; executeWithdraw would transfer it out of secondary to the owner-chosen recipient after the delay. Costs: an extra EPK per vault, two EBHub registrations on createVault, more state to manage on disable/cancel paths.
    • B. ZK verification Before debitForPayment, the CIS operator proves that the debit amount plus any already-declared pending withdrawal does not exceed the encrypted balance. Costs: an extra proof generation and verification on every debit operation.
  • Rate / cap controls on debits. Encrypted-amount caps need ZK constraints (proof verification) to be enforced trustlessly; public-amount caps on debitForPaymentToPublic could be added cheaply if needed.
  • Vaults are non-upgradeable and configs are immutable. Drop the Upgradeable Beacon and the live-config reads: the vault implementation is fixed at deploy time, and settlementEpk, settlementPublicAddress, and withdrawDelay are snapshotted into the vault at creation rather than read live. Admin loses the power to redirect settlement, shorten an in-flight withdraw window, or push behavior changes into existing vaults — the user only has to trust the implementation they signed up to. Costs: bug-fix path becomes "deploy a new vault implementation and migrate users individually" (each user has to executeWithdraw out and re-onboard); CIS rotation of settlement EPK requires every user to redeploy. Trade is more user trust-minimization for harder operational coordination.
  • EIP-712 entry points (SharedAccount + Paymaster support). Add *WithAuth overloads for each authorization-gated method on the vault — owner methods (withdraw lifecycle, EBEMT enable/disable, transferOwnership) and CIS operator methods (debit, debitToPublic). Each takes (params, nonce, deadline, signature) and validates the EIP-712 digest against the expected signer (owner via owner(); CIS operator via hub.isOperator(...)). Enables gasless UX through SharedAccount + Paymaster: the user / CIS signs off-chain, a sponsor relays.