Skip to main content
Confidential Payments let you send any standard ERC-20 token where the transfer amount is invisible on-chain. The ConfidentialPaymentRouterV2 contract wraps your tokens into a ConfidentialWrapper, moves them as an encrypted cToken, then unwraps and delivers plain ERC-20 to the receiver — all in a single flow. The receiver never needs to interact with FHE or hold cTokens.

Payment Flow

Sender: approve + send(token, receiver, amount, memo) + 0.00005 ETH fee
  → Router pulls ERC-20 from sender via safeTransferFrom
  → Router wraps tokens into cToken (amount is now encrypted on-chain)
  → Router transfers cToken internally
  → Router calls unwrap() — triggers KMS decryption request
  → Payment record created on-chain

Relayer: finalize(paymentId, handlesList, cleartexts, decryptionProof)
  → KMS proof submitted to wrapper's finalizeUnwrap()
  → Plain ERC-20 forwarded directly to receiver
  → Relayer receives the ETH fee as gas reimbursement
The finalize() function is permissionless — anyone can submit the KMS proof on behalf of a payment. The HideMe relayer does this automatically, but you or any third party can call it if needed.

send()

function send(
    address token,
    address receiver,
    uint256 amount,
    string calldata memo
) external payable returns (uint256 paymentId);
token
address
required
The ERC-20 token address you want to send. A ConfidentialWrapper must already exist for this token in WrapperFactory. Call getWrapper(token) on the factory first to verify it returns a non-zero address.
receiver
address
required
The wallet address that will receive the plain ERC-20 after finalization.
amount
uint256
required
Amount in the token’s native decimals (e.g. 1_000_000 for 1 USDC, 1e18 for 1 WETH).
memo
string
A human-readable note stored on-chain with the payment record.
You must also send at least 0.00005 ETH (MIN_RELAYER_FEE) as msg.value. This covers the relayer’s gas cost for the finalization transaction. Before calling send(), approve the router:
IERC20(token).approve(routerAddress, amount);
Estimated gas: ~1,500,000 for the full send() call.

finalize()

function finalize(
    uint256 paymentId,
    bytes32[] calldata handlesList,
    bytes calldata cleartexts,
    bytes calldata decryptionProof
) external;
Submits the Zama KMS threshold decryption proof for the payment’s unwrap request. Once verified:
  1. The wrapper’s finalizeUnwrap() is called, burning the cToken and releasing the underlying ERC-20.
  2. The underlying ERC-20 is forwarded to receiver.
  3. The relayerFee ETH is sent to the caller (the relayer).
paymentId
uint256
required
The ID returned by send() and emitted in the PaymentCreated event.
handlesList
bytes32[]
required
The FHE ciphertext handles being decrypted — from the PaymentUnwrapRequested event.
cleartexts
bytes
required
ABI-encoded decrypted values from the KMS network.
decryptionProof
bytes
required
KMS threshold signatures authorising the decryption.

cancel()

function cancel(uint256 paymentId) external;
If a payment’s KMS proof has not been submitted after 1 day, the original sender can cancel and recover their tokens. Cancellation:
  • Calls cancelUnwrap() on the wrapper to clear the restriction.
  • Returns the cToken balance to the sender via transferPlaintext.
  • Refunds the relayerFee ETH to the sender.
Only the original sender can cancel a payment, and only after PAYMENT_TIMEOUT = 1 days has passed since send() was called.

Payment struct fields

sender
address
Wallet that initiated the payment.
receiver
address
Wallet that will receive the plain ERC-20.
wrapper
address
Address of the ConfidentialWrapper used for this token.
amount
uint64
Amount in 6-decimal cToken units (after decimal adjustment from the underlying token).
unwrapRequestId
uint256
The request ID passed to the wrapper’s unwrap() function. Used in finalize().
handle
bytes32
The FHE ciphertext handle for the canUnwrap encrypted boolean submitted to the KMS.
relayerFee
uint256
ETH locked for the relayer, paid out on successful finalization.
memo
string
Human-readable note attached to the payment.
createdAt
uint256
Unix timestamp when send() was called.
finalized
bool
true after finalize() has been called successfully.
cancelled
bool
true if the sender cancelled after the timeout.

Batch Payments

You can send multiple payments in a single session. However, they must be processed sequentially — you must wait for each payment to be finalized before sending the next one using the same token. This is because the ConfidentialWrapper restricts the router’s account (isRestricted = true) during a pending unwrap. The restriction is cleared only after finalizeUnwrap() completes.
Send payment #0 → wait for finalize #0 → send payment #1 → wait for finalize #1 → ...
Payments for different tokens can be sent in parallel because each token has its own ConfidentialWrapper contract with independent restriction state.

Prerequisites

Before calling send(), verify that a ConfidentialWrapper exists for the token:
address wrapper = IWrapperFactory(WRAPPER_FACTORY_ADDRESS).getWrapper(token);
require(wrapper != address(0), "No wrapper for this token");
If no wrapper exists, send() will revert with WrapperNotFound(). You can deploy a new wrapper through WrapperFactory.createWrapper(token).

Contract Addresses

ContractNetworkAddress
PaymentRouterV2Ethereum Mainnet0x087D50Bb21a4C7A5E9394E9739809cB3AA6576Fa
WrapperFactoryEthereum Mainnet0xde8d3122329916968BA9c5E034Bbade431687408
cWETH WrapperEthereum Mainnet0x7a339078f9abde76c7cf9360238eafd2a64c0ee7
cUSDC WrapperEthereum Mainnet0x1704cd8697f1c4f21bab3e0c4cf149cb7b1e5147

Build docs developers (and LLMs) love