Card Issuer
A Card Issuer Service operator system: a Card Issuer Service (CIS) operates payment debits on behalf of a user without holding the user's keys. The user retains ownership and unilateral exit; the CIS is constrained on-chain to debit only into a designated settlement destination.
The system is two contracts:
CardIssuerHub— one per CIS deployment. UUPS-upgradeable. Combines deployment-wide config, roles, a vault factory + registry, and the lifecycle event sink.CardIssuerEpkVault— one per user. UUPS-upgradeable, owner-driven (with timelock). Registered as the EBHub controller of a single EPK. Mediates that EPK's transfers across multiple EBEMTs and ERC-20 tokens.
The hub exposes a single, indexer-stable address for monitoring; vaults forward lifecycle events to it via record* calls. Two reverse-compatible mappings index the registry: vaultOf[owner] → vault and ownerOf[vault] → owner (a vault is registered iff ownerOf[vault] != address(0)).
Roles
All roles are managed by DEFAULT_ADMIN_ROLE on the hub via OpenZeppelin AccessControl.
| Role | Held by | Authorized to |
|---|---|---|
DEFAULT_ADMIN_ROLE | CIS governance (typically a multisig) | Self-upgrade the hub, set/grant other roles, set deployment-wide config (settlement EPK + address, vault execution delay, vault implementation) |
CIS_ROLE | CIS service hot keys | Debit any enabled EBEMT or ERC-20 on any vault. Hub-wide — there is no per-vault CIS Operator assignment; rotation is a role grant on the hub |
PAUSER_ROLE | Operations / incident response | Toggle debitsPaused (deployment-wide) |
Vault owner | The user | Full vault management — withdraws (with timelock), enable/disable EBEMTs and tokens, upgrade the vault, transfer ownership |
The vault uses OpenZeppelin OwnableUpgradeable. All vault entry points are gated by msg.sender (Ownable for owner methods, cisHub.isOperator(msg.sender) for operator-only debits) — no EIP-712 signature is required from the caller today.
Hub: config
Vaults read these values live on every call. Admin updates take effect across every existing vault immediately.
| Field | Type | Setter | Notes |
|---|---|---|---|
vaultImplementation | address | setVaultImplementation (admin) | Used by createVault for new vaults only; existing vaults keep their own impl pointer |
settlementEpk | CompressedPoint | setSettlementEpk (admin) | Encrypted-side debit destination |
settlementAddress | address | setSettlementAddress (admin) | Public-side debit destination |
vaultExecutionDelay | uint256 | setVaultExecutionDelay (admin) | Snapshotted into each vault at creation; later changes affect new vaults only |
debitsPaused | bool | setDebitsPaused (pauser) | Read by every vault at the debit boundary; withdrawals never pause |
Note the asymmetry: vaultExecutionDelay is snapshotted per vault (so admin cannot shorten an in-flight delay), but settlement values are live (so the CIS can rotate sinks centrally).
Vault creation
Hub.createVault(address owner, Point epk, bytes proof) — permissionless. Atomically:
- Deploys an
ERC1967Proxypointing atvaultImplementation. - Initializes the vault with
(cisHub, epk, owner, vaultExecutionDelay). - Calls
EBHub.registerEpk(epk, vault, proof)so the vault is the EPK's on-chain controller. The proof is the standard EPK ownership proof (see Encrypted Balances). - Indexes the vault (
vaultOf[owner] = vault,ownerOf[vault] = owner).
If any step reverts the entire creation reverts.
One vault per owner. A given EVM address can only own one vault on this hub. The vault is bound to a single EPK; that EPK can hold balances in multiple EBEMTs and the vault mediates all of them.
registerVault and unregisterVault (both onlyRole(DEFAULT_ADMIN_ROLE)) provide registry hygiene for externally-deployed vaults or for retiring a vault from the hub's index. unregisterVault only flips the registry flags — the EBHub controllership of the vault's EPK is unaffected, so the vault keeps its on-chain authority over the EPK; only its hub-side event-emission privileges and owner index entry go away.
Vault: EBEMT and ERC-20 enable/disable
Before the CIS operator can debit a given EBEMT or ERC-20 token through a vault, the owner must explicitly enable it. Enable is instant; disable is timelocked by vaultExecutionDelay.
| Method (owner-only) | Effect |
|---|---|
enableEbemt(ebemt) / enableToken(token) | Sets ebemtEnabled[ebemt] (or tokenEnabled[token]) to true. Reverts if already enabled |
requestDisableEbemt(ebemt) / requestDisableToken(token) | Records block.timestamp; reverts if not currently enabled or already pending |
executeDisableEbemt(ebemt) / executeDisableToken(token) | After vaultExecutionDelay elapses: flips the enabled flag to false |
cancelDisableEbemt(ebemt) / cancelDisableToken(token) | Aborts a pending disable; the flag stays enabled |
The asymmetry is deliberate: enabling grants authority owner-side and is risk-free; disabling revokes authority and warrants a notice window so the operator can wrap up legitimate debits.
Debits — CIS operator → settlement
Three entry points on the vault, all gated by cisHub.isOperator(msg.sender) and all subject to !cisHub.debitsPaused():
| Method | Source | Recipient (enforced) | Authorization |
|---|---|---|---|
debitForPayment(token, amount) | Vault's plain ERC-20 balance | cisHub.settlementAddress() | tokenEnabled[token] |
debitEncryptedForPayment(ebemt, params) | EPK's encrypted balance on ebemt | cisHub.settlementEpk() | ebemtEnabled[ebemt] |
debitEncryptedToPublicForPayment(ebemt, params) | EPK's encrypted balance on ebemt | cisHub.settlementAddress() | ebemtEnabled[ebemt] |
The recipient is enforced in Solidity — the params struct's recipient field is compared against the live hub value, rather than relying on a signed digest to scope it. The encrypted-path operations forward to EBEMT via the msg.sender == controllerOf(senderEpk) authorization (see Encrypted Balances); the vault is the EPK's registered controller, so the vault→EBEMT call validates without producing a controller signature. This msg.sender linkage between vault and EBEMT is independent of whether the caller→vault hop later gains EIP-712 entry points.
Withdrawals — owner-driven, timelocked
Per-EBEMT and per-token. Both follow the same pattern:
- Request — owner declares intent, no funds move.
- Wait —
vaultExecutionDelayelapses. - Execute — owner provides full transfer params, vault performs the transfer.
- Cancel — owner aborts a pending request before execution.
Per-EBEMT and per-token slots are independent; the owner can have one in-flight per (vault, EBEMT) and one in-flight per (vault, token) simultaneously.
Encrypted withdraws
| Method | Effect |
|---|---|
requestEncryptedWithdraw(ebemt, transferAmount) | Stores the transferAmount ciphertext + block.timestamp. No proof, no balance movement |
executeEncryptedWithdraw(ebemt, params) | After delay: requires params.transferAmount to match the declared ciphertext field-for-field; calls EBEMT.encryptedTransfer to the owner-chosen params.recipientEpk; clears the slot |
cancelEncryptedWithdraw(ebemt) | Clears the slot |
The execute-time ciphertext binding is on transferAmount only; the recipient EPK is owner-chosen at execute time. The declaration is a public commitment of intent, not a fund reservation — see "Pending withdraw is declaration-only" below.
Token withdraws
| Method | Effect |
|---|---|
requestTokenWithdraw(token, amount) | Stores amount + block.timestamp. Reverts on amount == 0 |
executeTokenWithdraw(token, recipient) | After delay: SafeERC20.safeTransfer(token, recipient, storedAmount); clears the slot |
cancelTokenWithdraw(token) | Clears the slot |
Pending withdraw is declaration-only
Pending withdrawals are pure intent records. The CIS operator's debit authority is not affected by a pending withdrawal — they may continue to debit any amount during the delay window, and the owner accepts that whatever the operator drains before executeWithdraw is gone. The timelock is an off-chain coordination window, not a fund lock.
Activate pending balance
activatePendingEncrypted(address ebemt) — owner-only forwarder to EBEMT.activatePending(epk.compressPoint()). See Encrypted Balances → Pending Balance for the underlying mechanism. The vault doesn't expose deactivation directly; deactivation rides on the deactivatePending flag in the encrypted-debit / encrypted-withdraw params.
Vault upgrade
UUPS, owner-driven, timelocked. The hub admin cannot upgrade existing vaults; vaultImplementation on the hub only seeds new vaults. Existing users opt into upgrades on their own schedule.
| Method (owner-only) | Effect |
|---|---|
requestUpgrade(newImplementation) | Stores (newImplementation, block.timestamp) as the pending upgrade. Reverts if zero address or already pending |
cancelUpgrade() | Clears pending |
upgradeToAndCall(newImplementation, data) | Standard OZ UUPS entry point. Vault's _authorizeUpgrade requires: pending exists, newImplementation matches the declared value, and block.timestamp >= pendingUpgradeRequestAt + vaultExecutionDelay. Clears pending state on success |
There is no coordinated "upgrade every vault at once" path — vaults are independently upgradeable. A critical bug in the vault implementation is exploitable on every vault until each owner individually requests + waits + executes the upgrade. This is the trust trade for owner-controlled upgrades.
Vault ownership transfer
transferOwnership(newOwner) is an override of OZ Ownable.transferOwnership. It calls cisHub.recordChangeVaultOwner(oldOwner, newOwner) to update the hub's vaultOf and ownerOf mappings, then runs the underlying _transferOwnership(newOwner).
The hub-side call never reverts. If the constraint check fails (newOwner is zero, or vaultOf[newOwner] != address(0) — i.e. newOwner already has a vault), the hub emits VaultOwnerChangeFailed(vault, oldOwner, newOwner) and returns without changing state. On success, it updates both mappings and emits VaultOwnerChanged(vault, oldOwner, newOwner).
Because the hub call cannot revert, _transferOwnership always runs locally. In the failure case the vault and hub state desync: vault.owner() is updated to newOwner but the hub's vaultOf[newOwner] and ownerOf[vault] mappings are stale. The new owner can still operate the vault (the vault is the source of truth for owner), but hub-side lookups (cisHub.vaultOf(newOwner), cisHub.ownerOf(vault)) return stale data until reconciled. The VaultOwnerChangeFailed event is the off-chain signal that reconciliation is needed — the standard recovery is unregisterVault + registerVault from admin.
EBHub binding
The vault is registered as the EBHub controller of epk and does not implement ERC-1271. EBHub-domain operations that require controller authorization (notably EBHub.changeController) cannot be authorized through the vault, so the EPK is permanently bound to this vault. The owner's exit is executeEncryptedWithdraw to a different EPK they fully control.
Trust model and fail-closed / fail-open behavior
Vault state mutations call cisHub.record* to surface lifecycle events through the hub's stable address. The vault treats the two sides asymmetrically:
- Owner-side methods (withdraws, enable/disable, upgrade, transferOwnership) wrap
cisHub.record*intry/catch→ fail-open: hub reverts don't block the owner's action. The owner can always recover funds and manage their vault even if the hub-side event surface is broken.transferOwnershipadditionally goes throughrecordChangeVaultOwnerwhich never reverts but emitsVaultOwnerChangeFailedon a constraint violation; the desync is observable rather than silent. - Operator-side methods (the three debit methods) call
cisHub.record*directly → fail-closed: hub reverts block the debit. This is a kill-switch property — if admin unregisters a vault from the hub (unregisterVault), the CIS can no longer debit it, but the owner can still withdraw.
A compromised admin can:
- Upgrade the hub itself (UUPS).
- Redirect future debits via
setSettlementEpk/setSettlementAddress(live across all vaults). - Shorten or extend
vaultExecutionDelayfor future vaults only. - Grant or revoke any role.
- Unregister a vault from the hub registry (kill-switch for debits).
A compromised admin cannot push code into existing vaults (each vault upgrades independently) or shorten an in-flight vaultExecutionDelay on an existing vault.
A compromised CIS operator can drain to settlement on any vault that has the relevant EBEMT or token enabled, until either admin pauses debits, admin revokes the role, or each owner individually disables the EBEMT or token (timelocked).
Events
All vault lifecycle events live on the hub. The hub also emits its own config / governance events. Vault-emitted events all carry address indexed vault as the first topic so indexers can filter per vault.
| Event | Source | Notes |
|---|---|---|
VaultRegistered(vault, owner, epk) | Hub | Fires on createVault and registerVault. epk is the CompressedPoint |
VaultUnregistered(vault, owner) | Hub | Fires on unregisterVault |
VaultOwnerChanged(vault, oldOwner, newOwner) | Hub | Fires on a successful transferOwnership |
VaultOwnerChangeFailed(vault, oldOwner, newOwner) | Hub | Fires when recordChangeVaultOwner rejects the rotation (newOwner is zero or already owns a vault). Indicates vault-vs-hub desync — vault's owner did update locally |
Debited(vault, token, recipient, amount) | Hub | Plain ERC-20 debit |
EncryptedDebited(vault, ebemt, recipientEpk, amountCt) | Hub | Encrypted debit. amountCt is the transfer ciphertext |
EncryptedToPublicDebited(vault, ebemt, recipient, amount) | Hub | Layer-crossing debit |
EbemtEnabled / EbemtDisableRequested / EbemtDisableExecuted / EbemtDisableCancelled | Hub | EBEMT enable lifecycle |
TokenEnabled / TokenDisableRequested / TokenDisableExecuted / TokenDisableCancelled | Hub | ERC-20 enable lifecycle |
EncryptedWithdrawRequested / EncryptedWithdrawExecuted / EncryptedWithdrawCancelled | Hub | Encrypted withdraw lifecycle. The request event carries the declared transferAmount for off-chain monitoring |
TokenWithdrawRequested / TokenWithdrawExecuted / TokenWithdrawCancelled | Hub | ERC-20 withdraw lifecycle |
VaultUpgradeRequested / VaultUpgradeExecuted / VaultUpgradeCancelled | Hub | Vault upgrade lifecycle |
SettlementEpkUpdated / SettlementAddressUpdated / VaultExecutionDelayUpdated / DebitsPausedUpdated / VaultImplementationUpdated | Hub | Config / governance changes |