Skip to main content

ADR-014: BPK Controller Authorization

StatusImplemented
Date2026-04-01

Context

ADR-011 introduced a pending balance mechanism to prevent griefing attacks on balance-decreasing operations. In that model — and in the existing EBEMT contract — all actions from an encrypted balance account (BPK) are authorized by proving knowledge of the corresponding secret key BSK inside a ZK proof. Specifically:

  • concealedTransfer (sender side) — the ZK proof uses BSK as a private input to decrypt the current balance and compute the new balance. BSK knowledge is implicitly verified: if BSK is wrong, the decrypted balance will not match the on-chain ciphertext and the proof will fail.
  • revealAmount — same implicit BSK verification via decryption inside the circuit.
  • activatePending (from ADR-011) — a standalone ZK proof that verifies BSK * G == BPK. This is the only secret required; the operation has no other inputs beyond the BPK and a replay-protection nonce.

This design has two problems:

  1. External wallet incompatibility. BSK is a scalar on the Grumpkin curve (the embedded curve of BN254). It cannot be held in an EVM-native wallet — not in MetaMask, not on a Ledger, not in Fireblocks or any other institutional custody provider. The only way to "custody" funds in the current model is to custody the BSK, which means building custom key management outside the EVM ecosystem. This blocks integrations with existing wallet infrastructure.

  2. Unnecessary ZK proof overhead. activatePending requires a ZK proof verification on-chain solely to prove BSK ownership — there is no balance computation or other circuit logic involved. This is expensive (verifier gas cost) and slow (proof generation time) for what is conceptually a simple authorization check.

Proposal

Introduce a controller — an EVM address bound to each BPK that authorizes actions initiated from that BPK. The controller replaces BSK-based authorization at the contract level: operations are authorized by an EIP-712 signature from the controller, validated on-chain using OpenZeppelin's SignatureChecker (which supports both EOA signatures via ECDSA recovery and smart contract signatures via ERC-1271).

BSK remains necessary for ZK proof generation (decrypting balances, computing new ciphertexts), but it is no longer the authorization mechanism. This separates computation (BSK, held in client software) from custody (controller, held in any EVM wallet).

New storage

/// @notice Controller address for each registered BPK
mapping(CompressedPoint => address) public controllers;

/// @notice Replay protection for controller-authorized operations (per BPK, bitmap)
mapping(CompressedPoint => mapping(uint256 => uint256)) public noncesByBpk;

/// @notice Replay protection for concealAmountWithSig (per address, bitmap, replaces ERC20Permit nonces)
mapping(address => mapping(uint256 => uint256)) public noncesByAddress;

/// @notice Verifier for BSK ownership proofs (used by registerBpk)
IVerifier public bskOwnershipVerifier;

The pendingNonces mapping from ADR-011 is no longer needed — noncesByBpk serves the same purpose for activatePending and all other controller-authorized operations.

Non-sequential nonces

Both noncesByBpk and noncesByAddress use arbitrary (non-sequential) nonces, following the Permit2 model. The caller picks any unused uint256 nonce; the contract checks that it has not been used and marks it as consumed. This avoids the ordering problem with sequential nonces: if a user submits multiple operations concurrently (e.g., through different bundlers), they do not need to predict which transaction will land first. Each operation picks an independent nonce.

Functionally, this is equivalent to a simple mapping(uint256 => bool) — every nonce is used at most once. The bitmap is a pure gas optimization: it packs 256 nonce bits into a single storage slot, so the nonce is split into a word index (upper 248 bits) and a bit position (lower 8 bits):

function _useBpkNonce(CompressedPoint bpk, uint256 nonce) internal {
uint256 wordPos = nonce >> 8;
uint256 bitPos = nonce & 0xff;
uint256 bit = 1 << bitPos;
uint256 word = noncesByBpk[bpk][wordPos];
require(word & bit == 0, "Nonce already used");
noncesByBpk[bpk][wordPos] = word | bit;
}

SDKs that assign nonces from a contiguous range (e.g., 0, 1, 2, ...) benefit from warm-slot SSTORE costs (~5k gas) instead of cold-slot writes (~22k gas) for each nonce after the first in a given word. Users who pick random nonces see no difference — the bitmap is invisible to them and correctness is identical either way.

As a consequence, ERC20PermitUpgradeable is removed from the inheritance chain. The contract inherits EIP712Upgradeable directly (for _hashTypedDataV4() and the domain separator) and manages its own nonces for both concealAmountWithSig and controller-authorized operations. The standard ERC-20 permit() function is no longer available.

New events

event BpkRegistered(CompressedPoint indexed compressedBpk, address indexed controller);
event ControllerChanged(CompressedPoint indexed compressedBpk, address indexed oldController, address indexed newController);
event PendingUpdated(CompressedPoint indexed compressedBpk, bool enabled);

New EIP-712 type hashes

All controller-authorized operations use EIP-712 typed structured data. The EBEMT contract already inherits ERC20PermitUpgradeable which provides _hashTypedDataV4() and the domain separator, so no new infrastructure is needed.

bytes32 public constant ACTIVATE_PENDING_AUTH_TYPEHASH = keccak256(
"ActivatePendingAuth(bytes32 bpk,uint256 nonce,uint256 deadline)"
);

bytes32 public constant CONCEALED_TRANSFER_AUTH_TYPEHASH = keccak256(
"ConcealedTransferAuth(bytes32 senderBpk,bytes32 recipientBpk,bytes32 paramsHash,uint256 nonce,uint256 deadline)"
);

bytes32 public constant REVEAL_AMOUNT_AUTH_TYPEHASH = keccak256(
"RevealAmountAuth(bytes32 senderBpk,address recipient,uint256 amount,bytes32 paramsHash,uint256 nonce,uint256 deadline)"
);

bytes32 public constant CHANGE_CONTROLLER_AUTH_TYPEHASH = keccak256(
"ChangeControllerAuth(bytes32 bpk,address newController,uint256 nonce,uint256 deadline)"
);

The paramsHash field in ConcealedTransferAuth and RevealAmountAuth is keccak256(abi.encode(...)) over the remaining operation parameters (proof bytes, ciphertexts, etc.). This binds the controller's signature to the exact operation without bloating the EIP-712 struct — wallets display the human-readable fields (BPKs, recipient, amount) while paramsHash ensures the signature cannot be replayed with different proof data.

Signature validation

import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";

All controller signature checks use SignatureChecker.isValidSignatureNow(controller, digest, signature). SignatureChecker inspects controller.code.length to choose the validation path:

  • EOA controller (code.length == 0): standard ECDSA recovery — ecrecover recovers the signer from the 65-byte r || s || v signature and checks it matches the controller address. This is a purely mathematical operation that reads no on-chain state of the controller. In particular, the controller address does not need to have any balance, any transaction history, or any on-chain activity whatsoever — it only signs EIP-712 messages off-chain. This is important because the controller is expected to be a fresh, signing-only key (see Privacy note).
  • Smart contract controller (code.length > 0, ERC-1271): SignatureChecker calls controller.isValidSignature(digest, signature) via staticcall. The contract returns 0x1626ba7e if valid. This supports Safe multisigs, Fireblocks smart accounts, ERC-4337 wallets, and any other ERC-1271-compliant contract.

Both paths are supported transparently — the caller does not need to specify which type of controller is being used. The signature parameter is bytes calldata signature (not the (uint8 v, bytes32 r, bytes32 s) triple used by the existing concealAmountWithSig) to accommodate arbitrary-length smart contract signatures.

Note on undeployed smart contract wallets: If a smart contract wallet is used as a controller but has not yet been deployed (counterfactual address, common in ERC-4337), SignatureChecker will see code.length == 0 and attempt ECDSA recovery, which will fail since no private key corresponds to the contract address. Users who want a smart contract controller must ensure it is deployed before calling registerBpk. This is a known limitation; ERC-6492 addresses this for pre-deployment signature validation but is not adopted here to keep the on-chain logic simple.

New functions

registerBpk(Point calldata bpk, address controller, bytes calldata proof)

Binds a BPK to a controller address. This is a one-time operation — once registered, the controller can only be changed via changeController.

function registerBpk(Point calldata bpk, address controller, bytes calldata proof) external {
require(controllers[compressBpk(bpk)] == address(0), "BPK already registered");
// Verify ZK proof: BSK * G == BPK, with controller as unconstrained public input
require(bskOwnershipVerifier.verify(proof, [bpk.x, bpk.y, controller]), InvalidProof());
controllers[compressBpk(bpk)] = controller;
}

The ZK proof is the same trivial circuit from ADR-011 (BSK * G == BPK), extended with the controller address as a public input. The controller is exposed in the circuit as an unconstrained public input — identical to the aux_commitment pattern — and the contract checks that the submitted controller matches the proof's public input. This prevents a frontrunning attack where a MEV bot copies a valid proof from the mempool and submits it with a different controller address.

The function has no msg.sender check: anyone can submit the transaction. This is compatible with the SharedAccount + Paymaster flow from ADR-012 — the registration can be submitted as a UserOperation through the SharedAccount, sponsored by the Paymaster Backend.

changeController(CompressedPoint bpk, address newController, uint256 nonce, uint256 deadline, bytes calldata signature)

Allows the current controller to delegate control to a new address. This is the only way to change a BPK's controller after registration — even with knowledge of BSK, an attacker cannot override the controller.

function changeController(CompressedPoint bpk, address newController, uint256 nonce, uint256 deadline, bytes calldata signature) external {
_useBpkNonce(bpk, nonce);
// Validate EIP-712 signature from current controller over (bpk, newController, nonce, deadline)
bytes32 digest = _hashTypedDataV4(keccak256(
abi.encode(CHANGE_CONTROLLER_AUTH_TYPEHASH, bpk, newController, nonce, deadline)
));
require(SignatureChecker.isValidSignatureNow(controllers[bpk], digest, signature));
controllers[bpk] = newController;
}

Modified functions

activatePending — ZK proof eliminated

In ADR-011, activatePending required a ZK proof to verify BSK * G == BPK. With controller authorization, this is replaced by a simple EIP-712 signature — no proof generation, no on-chain verifier call.

function activatePending(CompressedPoint bpk, uint256 nonce, uint256 deadline, bytes calldata signature) external {
_useBpkNonce(bpk, nonce);
// Same pattern as changeController: validate EIP-712 signature from controller over (bpk, nonce, deadline)
bytes32 digest = _hashTypedDataV4(keccak256(
abi.encode(ACTIVATE_PENDING_AUTH_TYPEHASH, bpk, nonce, deadline)
));
require(SignatureChecker.isValidSignatureNow(controllers[bpk], digest, signature));
pendingActive[bpk] = true;
emit PendingUpdated(bpk, true);
}

This eliminates the BSK ownership verifier deployment and the proof generation latency for activation. The pendingNonces mapping from ADR-011 is superseded by noncesByBpk.

concealedTransfer — controller signature added, authorizedCaller removed

The authorizedCaller parameter and its msg.sender check are removed. Authorization is provided by a controller signature over the operation parameters. The new function signature is:

function concealedTransfer(
bytes calldata proof,
Point calldata senderBpk,
ElGamalCiphertext calldata newSenderBalance,
ElGamalCiphertext calldata transferAmount,
ElGamalCiphertext calldata trcCiphertext,
Point calldata recipientBpk,
bool clearPending, // from ADR-011
bool deactivatePending, // from ADR-011
uint256 nonce, // new: arbitrary nonce for replay protection
uint256 deadline, // new: signature expiry
bytes calldata controllerSignature // new: replaces authorizedCaller
) external;

The controller authorization block (inserted before the existing ZK proof verification) follows the same pattern as changeController:

// Controller authorization — binds signature to all operation parameters
_useBpkNonce(compressedSender, nonce);
bytes32 paramsHash = keccak256(abi.encode(proof, newSenderBalance, transferAmount, trcCiphertext, clearPending, deactivatePending));
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
CONCEALED_TRANSFER_AUTH_TYPEHASH, compressedSender, compressedRecipient, paramsHash, nonce, deadline
)));
require(SignatureChecker.isValidSignatureNow(controllers[compressedSender], digest, controllerSignature));

// ZK proof verification — auxCommitment now includes nonce and deadline
bytes32 auxCommitment = keccak256(abi.encode(clearPending, deactivatePending, nonce, deadline));
// ... publicInputs include auxCommitment instead of authorizedCaller (rest unchanged from ADR-011)

auxCommitment extends ADR-011's pattern: it includes clearPending and deactivatePending (from ADR-011) plus nonce and deadline (from this ADR). The circuit exposes auxCommitment as a public input without constraining it — the contract recomputes and checks the match. This binds the ZK proof to both the pending-balance flags and the controller authorization parameters, without adding new public inputs to the circuit beyond what ADR-011 already introduces.

The authorizedCaller public input is removed from the circuit. Since all operations now go through SharedAccount (ADR-012) and authorization comes from the controller signature, msg.sender-based access control is no longer needed.

The rest of the function body (sender balance update, recipient pending routing, pending balance management) is unchanged from ADR-011.

revealAmount — controller signature added

Same pattern as concealedTransfer: the authorizedCaller parameter is removed, and a controller signature is added alongside the existing ZK proof. The new parameters are clearPending, deactivatePending (from ADR-011), nonce, deadline, and controllerSignature. The controller authorization and auxCommitment logic is identical to concealedTransfer above, using REVEAL_AMOUNT_AUTH_TYPEHASH with fields (senderBpk, recipient, amount, paramsHash, nonce, deadline).

concealAmount — unchanged

Incoming deposits via concealAmount are not affected by controller authorization. They do not spend from a BPK — they add to one. The token holder (EVM address) authorizes the burn via msg.sender as before. Deposits work for any valid BPK regardless of registration status.

concealAmountWithSig — nonce model changed

concealAmountWithSig is not affected by controller authorization (it authorizes via the token holder's EIP-712 signature, not a controller). However, its nonce model changes: the sequential nonces inherited from ERC20PermitUpgradeable are replaced with the non-sequential concealNonces bitmap. The nonce parameter becomes caller-supplied (an arbitrary unused uint256) instead of auto-incremented. The (uint8 v, bytes32 r, bytes32 s) signature format is also replaced with bytes calldata signature for ERC-1271 compatibility.

A helper _useBpkNonce(CompressedPoint bpk, uint256 nonce) checks the nonce bit is unset and flips it (see Non-sequential nonces above). Similarly, _useAddressNonce(address owner, uint256 nonce) does the same for noncesByAddress.

ERC-4337 compatibility

The controller authorization model is fully compatible with the SharedAccount + Paymaster architecture from ADR-012:

  1. The SDK constructs the operation parameters and generates the ZK proof (using BSK held locally).
  2. The controller signs the EIP-712 typed data — this can happen in MetaMask, Ledger, Fireblocks, or any ERC-1271-compliant smart wallet.
  3. The SDK packs the proof, parameters, and controller signature into a UserOperation with sender = SharedAccount.
  4. The Paymaster Backend validates and co-signs the UserOperation.
  5. The bundler submits the UserOperation to the EntryPoint.
  6. SharedAccount forwards the call to EBEMT.
  7. EBEMT validates the controller signature (via SignatureChecker) and the ZK proof.

No function has a msg.sender dependency — the SharedAccount is just a forwarder, and the controller signature provides authorization.

ZK circuit changes

Compared to ADR-011:

  • BSK ownership circuit — still needed, but only for registerBpk. Extended with controller as an additional unconstrained public input. No longer used by activatePending.
  • concealedTransfer circuit — the authorizedCaller public input is removed. The aux_commitment public input (introduced in ADR-011) now includes nonce and deadline in its hash preimage. No other circuit changes.
  • revealAmount circuit — same change as concealedTransfer: aux_commitment extended with nonce and deadline.

Privacy note

The controller is an EVM address stored on-chain in the controllers mapping. To avoid leaking unnecessary links between a BPK and a user's real-world identity:

  • Users should create a fresh, previously unused key for the controller. It should not be reused for any other purpose.
  • The controller's ETH balance is expected to be zero. No transactions are ever sent from the controller address — all operations go through the SharedAccount (ADR-012), so the controller never appears as msg.sender or tx.origin.
  • The controller only signs EIP-712 messages; it does not transact on-chain. This makes it compatible with air-gapped signers and custody solutions that support off-chain signing but not transaction submission.

If the controller is a smart contract wallet (e.g., a Safe multisig deployed for institutional custody), that contract's address and deployment will be visible on-chain. Users who prioritize unlinkability should use a plain EOA as the controller.

Security properties

ThreatMitigation
BSK compromisedAttacker can generate valid ZK proofs but cannot submit them — controller signature is required. Funds are safe as long as the controller key is secure.
Controller key compromisedAttacker can sign authorizations but cannot generate valid ZK proofs without BSK — proof requires correct balance decryption. Funds are safe as long as BSK is secure.
Both compromisedAttacker has full control. Same as current model (BSK compromise alone gives full control today).
Frontrunning registerBpkController address is a public input to the ZK proof — a MEV bot cannot reuse the proof with a different controller.
Replay attackNon-sequential per-BPK nonce bitmaps in noncesByBpk prevent signature replay (each nonce can only be consumed once). deadline provides time-bounded validity. auxCommitment binds the ZK proof to the same nonce and deadline.
Re-registration attackregisterBpk reverts if the BPK already has a controller. Controller change only via changeController, signed by the current controller.

Consequences

What becomes easier:

  • External wallet integration. The controller can be any EVM address — an EOA in MetaMask, a key on a Ledger, an institutional custody account in Fireblocks, or a Safe multisig. No custom key management infrastructure is needed for custody; BSK is a client-side computational secret, not the custody key.
  • Smart contract wallet support. ERC-1271 compatibility via SignatureChecker means the controller can be any smart contract that implements isValidSignature — multisigs, session-key wallets, social recovery contracts, etc.
  • Cheaper activatePending. Replaced from an on-chain ZK proof verification (~200k+ gas) to a signature check (~10k gas for ECDSA, ~30k for ERC-1271). Proof generation latency is also eliminated.
  • Stronger custody model. Compromising BSK alone is no longer sufficient to steal funds. An attacker needs both BSK (for proof generation) and the controller key (for authorization). This is strictly stronger than the current model.
  • ERC-4337 alignment. The signature-based authorization model fits naturally with the SharedAccount + Paymaster flow from ADR-012 — no msg.sender checks, no authorizedCaller parameter, just cryptographic authorization.

What becomes harder:

  • Registration step required. Every BPK must be registered with a controller before it can be used for outgoing operations (concealedTransfer, revealAmount, activatePending). This adds a one-time setup cost: one ZK proof generation and verification for registerBpk.
  • Additional signature per operation. Every balance-decreasing operation now requires both a ZK proof and a controller signature, adding gas cost (~6.7k for SignatureChecker with ECDSA, more for ERC-1271) and a signing step in the client flow.
  • Two secrets to manage. Users must safeguard both BSK (in client software) and the controller key (in their wallet). Losing either one blocks spending — though only losing both to an attacker results in fund theft.
  • New storage. Three new mappings (controllers, noncesByBpk, noncesByAddress) increase contract state. The pendingNonces mapping from ADR-011 is removed, and the inherited permit _nonces mapping is dropped.