ADR-023: Card-Issuer Vault for EBEMT
| Status | Proposed |
| Date | 2026-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
grossBalanceto 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_ROLEfor governance,CIS_ROLEfor CIS operators (any holder may debit any vault — there is no per-vault CIS operator assignment), andPAUSER_ROLEfordebitsPaused. - Factory + registry:
createVault(...)deploys aBeaconProxy, atomically registersepkon 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 wholecreateVaultis 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 (
transferOwnershipis overridden to atomically update the hub'svaultOfmapping). - CIS Operator — any address holding
CIS_ROLEon the hub. Debitsepkto eithersettlementEpk(encrypted) orsettlementPublicAddress(public). No per-vault CIS operator assignment; CIS-side hot-key rotation is handled by granting/revokingCIS_ROLEdeployment-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 thetransferAmountciphertext andblock.timestampfor that EBEMT. No proof, no balance movement.- After
vaultExecutionDelayelapses,executeWithdraw(ebemt, ...)performs the actual transfer. The vault binds the executedtransferAmountto 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)→ waitvaultExecutionDelay→executeDisableEbemt(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) orsettlementPublicAddress(public). Recipient is enforced in Solidity, not via signature scoping. - Subject to
debitsPaused(PAUSER-controlled, deployment-wide) and per-vaultebemtEnabled[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
vaultOfmapping.
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_ROLEis 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.transferOwnershipis overridden to atomically update the hub'svaultOfmapping; 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
executeWithdrawto 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
registerEpkrequires 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.
requestWithdrawwould transfer the declared amount from primary to secondary;executeWithdrawwould transfer it out of secondary to the owner-chosen recipient after the delay. Costs: an extra EPK per vault, two EBHub registrations oncreateVault, 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.
- 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.
- Rate / cap controls on debits.
Encrypted-amount caps need ZK constraints (proof verification) to be enforced trustlessly; public-amount caps on
debitForPaymentToPubliccould 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, andwithdrawDelayare 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 toexecuteWithdrawout 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
*WithAuthoverloads 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 viaowner(); CIS operator viahub.isOperator(...)). Enables gasless UX through SharedAccount + Paymaster: the user / CIS signs off-chain, a sponsor relays.