Skip to main content

ADR-011: Pending Balance for Anti-Griefing

StatusProposed
Date2026-03-17

Context

The current concealedTransfer function updates the recipient's encrypted balance via on-chain EC point addition (lines 254-258 of EBEMT.sol). This means any third party can modify a user's encryptedBalances entry at any time by sending them a transfer.

Every balance-modifying operation (concealedTransfer, revealAmount, and future operations like stake) includes the sender's current encryptedBalances value as a public input to the ZK proof. The proof is only valid if this value matches on-chain storage at the time of verification. If someone sends a transfer to the user between proof generation and proof submission, the stored balance changes and the proof becomes invalid — the transaction reverts.

This is a griefing vector: an attacker can monitor the mempool and frontrun any balance-decreasing operation with a dust transfer to the victim, invalidating their proof at near-zero cost. The victim must re-read their balance, regenerate the proof, and resubmit — only to be griefed again. The attack requires no special permissions and costs only the gas of a small concealedTransfer.

Proposal

Introduce a pending balance mechanism that gives users control over when incoming transfers are merged into their main balance.

New storage

/// @notice Pending encrypted balance for each BPK (receives incoming transfers when active)
mapping(CompressedPoint => ElGamalCiphertext) public pendingBalances;

/// @notice Whether incoming transfers are directed to the pending balance
mapping(CompressedPoint => bool) public pendingActive;

/// @notice Nonce for activatePending replay protection
mapping(CompressedPoint => uint256) public pendingNonces;

New functions

activatePending(Point calldata bpk, uint256 nonce, bytes calldata proof)

Sets pendingActive[compressBpk(bpk)] = true. Requires a ZK proof that the caller knows the secret key corresponding to bpk — specifically, the proof verifies BSK * G == BPK. The contract checks nonce == pendingNonces[compressedBpk] and increments the nonce on success, preventing replay (without this, anyone could copy a past proof from on-chain and force-reactivate pending on a BPK). This is a standalone function because there is no existing circuit-based operation to piggyback on (the user wants to activate protection before making a transfer).

After activation, all incoming concealedTransfer and concealAmount operations directed at this BPK add to pendingBalances instead of encryptedBalances.

The ZK proof preserves deniability: it does not link the BPK to any EVM address, so an observer cannot distinguish a BPK owner activating their own pending balance from any other scenario.

There is no standalone deactivatePending or clearPending function. These operations are folded into balance-decreasing operations as parameters (see below).

Modified functions

concealedTransfer — recipient-side change

The recipient balance update (currently lines 253-258) changes to:

if (pendingActive[compressedRecipient]) {
ElGamalCiphertext storage pending = pendingBalances[compressedRecipient];
(pending.R_x, pending.R_y) =
Grumpkin.add(pending.R_x, pending.R_y, transferAmount.R_x, transferAmount.R_y);
(pending.C_x, pending.C_y) =
Grumpkin.add(pending.C_x, pending.C_y, transferAmount.C_x, transferAmount.C_y);
} else {
ElGamalCiphertext storage recipientBal = encryptedBalances[compressedRecipient];
(recipientBal.R_x, recipientBal.R_y) =
Grumpkin.add(recipientBal.R_x, recipientBal.R_y, transferAmount.R_x, transferAmount.R_y);
(recipientBal.C_x, recipientBal.C_y) =
Grumpkin.add(recipientBal.C_x, recipientBal.C_y, transferAmount.C_x, transferAmount.C_y);
}

The sender-side logic is unchanged — the sender's encryptedBalances is still the public input and is still replaced with newSenderBalance.

concealAmount — same routing

When pendingActive is true for the target BPK, concealAmount adds the shielded amount to pendingBalances instead of encryptedBalances.

Balance-decreasing operations — clearPending and deactivatePending parameters

concealedTransfer (sender side) and revealAmount gain two boolean parameters:

  • clearPending (default: true) — when true, merges pendingBalances into encryptedBalances via on-chain EC point addition after the main operation completes, then resets pendingBalances to zero.
  • deactivatePending (default: true) — when true, sets pendingActive = false after the main operation completes.

These parameters are bound to the proof via an aux_commitment public input — a keccak256 hash of all contract-level parameters that the circuit does not reason about but must be tied to the proof. The contract recomputes the hash from the submitted parameters and checks it matches. This prevents anyone from replaying a valid proof with different flags. The merge and deactivation happen atomically after the proof-verified main operation, so they cannot be griefed. The contract skips both when pendingActive[sender] is already false, avoiding unnecessary EC additions.

The user must decrypt the merged result locally to learn their new plaintext balance before generating subsequent proofs.

Usage patterns

Default (no protection). Keep pendingActive = false. Incoming transfers modify encryptedBalances directly, as today. The griefing vector remains, but no workflow changes are needed.

Reactive. Keep pendingActive = false by default. If a transfer gets griefed, call activatePending(bpk, proof) to freeze incoming transfers, then retry. The next successful concealedTransfer or revealAmount will clear the pending balance and deactivate protection automatically (both parameters default to true). This is the expected common case — most users will never be griefed, and those who are pay the activation cost only once per incident.

Always-on. Call activatePending once and pass deactivatePending=false on every concealedTransfer / revealAmount. The pending balance is cleared on each operation but protection stays active. Incoming transfers are never spendable until the next outgoing operation. Suited for high-value accounts that want permanent protection.

Batch. When submitting multiple operations offline (e.g., queued transfers), pass clearPending=false on all but the last one. This avoids repeated merge overhead — pending funds are absorbed only once, at the end of the batch.

ZK circuit changes

  • One new trivial ZK circuit is required: the BSK ownership proof used by activatePending. It proves BSK * G == BPK (a single scalar multiplication and equality check). Public inputs: BPK, nonce. Private input: BSK.
  • Existing circuits (concealedTransfer, revealAmount) gain one new public input: aux_commitment. The circuit exposes this value without constraining it — binding is done externally via keccak256. No other changes to existing circuits.

Consequences

What becomes easier:

  • Users have a reliable way to prevent griefing attacks on their balance-decreasing operations. The reactive pattern handles griefing with minimal overhead — most users never pay the activation cost.
  • The mechanism is opt-in and backward-compatible. Users who do not activate pending balance experience no change in behavior.
  • Folding clearPending and deactivatePending into existing operations avoids extra transactions — protection activates with one standalone call and deactivates for free on the next transfer.
  • Deniability is preserved: the BSK ownership proof does not link BPK to any EVM address, so forwarding setups (where an address registers someone else's BPK) remain indistinguishable from personal use.

What becomes harder:

  • The contract gains additional storage (pendingBalances, pendingActive) and branching logic in the transfer and conceal paths, increasing gas costs slightly for all transfers (one extra storage read for the pendingActive check).
  • activatePending requires a ZK proof verification, which is more expensive than a simple storage write. This cost is acceptable because activation is infrequent — typically a one-time response to griefing, or once per account for always-on users.
  • One new ZK circuit must be implemented and its verification key deployed on-chain, though the circuit is trivial (single scalar multiplication).