Skip to main content

System Overview

ZKP2P v2.1 implements a modular, intent-based architecture for trustless peer-to-peer fiat-to-crypto exchanges. The system consists of five main components that work together to facilitate secure, verifiable trades:
  1. Escrow Contract - Liquidity management and deposit custody
  2. Orchestrator Contract - Intent lifecycle coordination
  3. Unified Payment Verifier - Multi-platform payment validation
  4. Registry System - Permission and configuration management
  5. Protocol Viewer - Read-only state aggregation
The v2.1 architecture introduces a unified verification system that consolidates multiple payment method verifiers into a single, configurable contract, significantly reducing deployment complexity.

Architecture Diagram

Core Components

Escrow Contract

The Escrow contract manages liquidity deposits from makers (liquidity providers) and handles secure fund custody.

Deposit Management

Creates, updates, and closes liquidity deposits with configurable parameters

Payment Method Config

Supports multiple payment methods per deposit with currency-specific rates

Intent Locking

Temporarily locks funds when takers signal intent to trade

Liquidity Reclaim

Automatically reclaims liquidity from expired intents

Key Responsibilities

Makers can create deposits with specific parameters:
Escrow.sol (line 139)
function createDeposit(CreateDepositParams calldata _params) 
    external 
    whenNotPaused 
{
    // Validates parameters
    if (_params.intentAmountRange.min == 0) revert ZeroMinValue();
    if (_params.amount < _params.intentAmountRange.min) {
        revert AmountBelowMin(_params.amount, _params.intentAmountRange.min);
    }
    
    // Creates deposit with unique ID
    uint256 depositId = depositCounter++;
    deposits[depositId] = Deposit({
        depositor: msg.sender,
        token: _params.token,
        intentAmountRange: _params.intentAmountRange,
        acceptingIntents: true,
        remainingDeposits: _params.amount,
        outstandingIntentAmount: 0,
        // ...
    });
    
    // Transfers tokens to escrow
    _params.token.safeTransferFrom(msg.sender, address(this), _params.amount);
}
Each deposit includes:
  • Token type (USDC)
  • Amount range for individual intents (min/max)
  • Supported payment methods and currencies
  • Optional delegate for deposit management
  • Optional intent guardian for expiry extensions
Each deposit can accept multiple payment methods, and each method can support multiple currencies:
// Example: Deposit supports Venmo and Revolut
// Venmo accepts: USD (1:1 rate)
// Revolut accepts: USD (1:1), EUR (1.2:1), GBP (1.3:1)

mapping(uint256 => mapping(bytes32 => mapping(bytes32 => uint256))) 
    internal depositCurrencyMinRate;
This flexibility allows makers to:
  • Accept payments from multiple platforms
  • Support international currencies
  • Set custom conversion rates per currency
  • Update rates dynamically based on market conditions
When a taker signals intent, the Orchestrator calls lockFunds:
Escrow.sol (line 557)
function lockFunds(
    uint256 _depositId, 
    bytes32 _intentHash,
    uint256 _amount
) 
    external
    onlyOrchestrator 
{
    // Validates deposit state
    Deposit storage deposit = deposits[_depositId];
    if (!deposit.acceptingIntents) revert DepositNotAcceptingIntents(_depositId);
    
    // Reclaims expired intent liquidity if needed
    bytes32[] memory expiredIntents = _reclaimLiquidityIfNecessary(
        deposit, _depositId, _amount
    );
    
    // Locks liquidity
    deposit.remainingDeposits -= _amount;
    deposit.outstandingIntentAmount += _amount;
    
    // Creates intent with expiry
    uint256 expiryTime = block.timestamp + intentExpirationPeriod;
    depositIntents[_depositId][_intentHash] = Intent({
        intentHash: _intentHash,
        amount: _amount,
        timestamp: block.timestamp,
        expiryTime: expiryTime
    });
}
This ensures:
  • Liquidity is reserved for the specific intent
  • Intents expire after a configurable period
  • Expired intents are pruned to reclaim liquidity
To prevent small leftover balances:
Escrow.sol (line 989)
function _closeDepositIfNecessary(uint256 _depositId, Deposit storage _deposit) 
    internal 
{
    uint256 totalRemaining = _deposit.remainingDeposits;
    if (_deposit.outstandingIntentAmount == 0 && 
        totalRemaining <= dustThreshold && 
        !_deposit.retainOnEmpty) 
    {
        // Close deposit and sweep dust
        IERC20 token = _deposit.token;
        _closeDeposit(_depositId, _deposit);
        
        if (totalRemaining > 0) {
            token.safeTransfer(dustRecipient, totalRemaining);
            emit DustCollected(_depositId, totalRemaining, dustRecipient);
        }
    }
}
  • Dust threshold prevents tiny balances from remaining
  • retainOnEmpty flag allows makers to keep deposit config
  • Protocol collects dust to avoid locked funds

Orchestrator Contract

The Orchestrator coordinates the entire intent lifecycle from creation to settlement.

Intent Coordination

Manages intent creation, cancellation, and fulfillment

Payment Verification

Routes verification requests to appropriate verifiers

Fee Collection

Distributes protocol fees and referrer commissions

Hook Execution

Executes optional post-intent hooks for custom logic

Intent Lifecycle

1

Signal Intent

Taker signals their intention to trade:
Orchestrator.sol (line 102)
function signalIntent(SignalIntentParams calldata _params)
    external
    whenNotPaused
{
    // Validates intent parameters
    _validateSignalIntent(_params);
    
    // Calculates unique intent hash
    bytes32 intentHash = _calculateIntentHash();
    
    // Stores intent with all parameters
    intents[intentHash] = Intent({
        owner: msg.sender,
        to: _params.to,
        escrow: _params.escrow,
        depositId: _params.depositId,
        amount: _params.amount,
        paymentMethod: _params.paymentMethod,
        fiatCurrency: _params.fiatCurrency,
        conversionRate: _params.conversionRate,
        payeeId: depData.payeeDetails,
        timestamp: block.timestamp,
        referrer: _params.referrer,
        referrerFee: _params.referrerFee,
        postIntentHook: _params.postIntentHook
    });
    
    // Locks funds on escrow
    IEscrow(_params.escrow).lockFunds(_params.depositId, intentHash, _params.amount);
}
2

Off-Chain Payment

Taker sends fiat payment through the specified payment platform (Venmo, PayPal, etc.) to the maker’s payee details.
This step happens entirely off-chain. The protocol does not control or monitor the payment itself.
3

Fulfill Intent

Anyone can submit payment proof to fulfill the intent:
Orchestrator.sol (line 184)
function fulfillIntent(FulfillIntentParams calldata _params) 
    external 
    nonReentrant 
    whenNotPaused 
{
    Intent memory intent = intents[_params.intentHash];
    
    // Gets verifier from registry
    address verifier = paymentVerifierRegistry.getVerifier(intent.paymentMethod);
    
    // Verifies payment proof
    IPaymentVerifier.PaymentVerificationResult memory result = 
        IPaymentVerifier(verifier).verifyPayment(
            IPaymentVerifier.VerifyPaymentData({
                intentHash: _params.intentHash,
                paymentProof: _params.paymentProof,
                data: _params.verificationData
            })
        );
    
    if (!result.success) revert PaymentVerificationFailed();
    
    // Unlocks and transfers funds
    IEscrow(intent.escrow).unlockAndTransferFunds(
        intent.depositId, 
        _params.intentHash, 
        result.releaseAmount, 
        address(this)
    );
    
    // Distributes fees and transfers to recipient
    _collectFeesTransferFundsAndExecuteAction(
        deposit.token,
        _params.intentHash,
        intent,
        result.releaseAmount,
        _params.postIntentHookData
    );
}
4

Settlement

Funds are distributed to all parties:
  1. Protocol Fee - Sent to protocol fee recipient
  2. Referrer Fee - Sent to referrer (if specified)
  3. Net Amount - Sent to taker or post-intent hook
Orchestrator.sol (line 475)
function _calculateAndTransferFees(
    IERC20 _token,
    Intent memory _intent, 
    uint256 _releaseAmount
) internal returns (uint256 netFees) {
    // Protocol fee (1% default)
    if (protocolFeeRecipient != address(0) && protocolFee > 0) {
        protocolFeeAmount = (_releaseAmount * protocolFee) / PRECISE_UNIT;
        _token.safeTransfer(protocolFeeRecipient, protocolFeeAmount);
    }
    
    // Referrer fee (up to 50%)
    if (_intent.referrer != address(0) && _intent.referrerFee > 0) {
        referrerFeeAmount = (_releaseAmount * _intent.referrerFee) / PRECISE_UNIT;
        _token.safeTransfer(_intent.referrer, referrerFeeAmount);
    }
    
    netFees = protocolFeeAmount + referrerFeeAmount;
}

Intent Gating

Orchestrator supports optional signature-based gating:
Orchestrator.sol (line 428)
address intentGatingService = IEscrow(_intent.escrow).getDepositGatingService(
    _intent.depositId, _intent.paymentMethod
);

if (intentGatingService != address(0)) {
    if (block.timestamp > _intent.signatureExpiration) {
        revert SignatureExpired(_intent.signatureExpiration, block.timestamp);
    }
    
    if (!_isValidIntentGatingSignature(_intent, intentGatingService)) {
        revert InvalidSignature();
    }
}
Gating allows makers to restrict who can take their liquidity, enabling compliance, KYC requirements, or whitelist-based access.

Unified Payment Verifier

The Unified Payment Verifier consolidates verification logic for all payment methods into a single contract.

Architecture Benefits

Reduced Complexity

One contract instead of 8+ separate verifiers

Consistent Interface

Standardized verification across all payment methods

Easy Configuration

Per-method settings without deploying new contracts

Lower Gas Costs

Shared logic and optimized verification flow

Verification Flow

UnifiedPaymentVerifier.sol
function verifyPayment(VerifyPaymentData calldata _data) 
    external 
    returns (PaymentVerificationResult memory) 
{
    // 1. Decode payment attestation (EIP-712 signed)
    PaymentAttestation memory attestation = abi.decode(
        _data.paymentProof, (PaymentAttestation)
    );
    
    // 2. Verify attestation signatures
    bool isValid = attestationVerifier.verifyAttestation(
        attestation.dataHash,
        attestation.signatures
    );
    if (!isValid) revert InvalidAttestation();
    
    // 3. Decode and validate payment details
    PaymentDetails memory payment = abi.decode(
        attestation.data, (PaymentDetails)
    );
    
    // 4. Verify payment matches intent
    IntentSnapshot memory intentSnap = _getIntentSnapshot(
        attestation.intentHash
    );
    
    if (payment.method != intentSnap.paymentMethod) revert MethodMismatch();
    if (payment.payeeId != intentSnap.payeeDetails) revert PayeeMismatch();
    if (payment.currency != intentSnap.fiatCurrency) revert CurrencyMismatch();
    
    // 5. Verify timestamp within buffer
    uint256 timeDiff = payment.timestamp - intentSnap.signalTimestamp;
    if (timeDiff > intentSnap.timestampBuffer) revert TimestampOutOfRange();
    
    // 6. Nullify payment to prevent double-spend
    nullifierRegistry.addNullifier(payment.paymentId);
    
    return PaymentVerificationResult({
        success: true,
        intentHash: attestation.intentHash,
        releaseAmount: attestation.releaseAmount
    });
}
Critical Security: The nullifier registry prevents payment proofs from being reused. Each payment ID can only be used once across the entire protocol.

Registry System

The registry system provides modular permission management:
Maps payment methods to verifier contracts and supported currencies:
// Payment method => Verifier address
mapping(bytes32 => address) public verifiers;

// Payment method => Currency => Supported
mapping(bytes32 => mapping(bytes32 => bool)) public supportedCurrencies;

function addPaymentMethod(
    bytes32 _method,
    address _verifier,
    bytes32[] calldata _currencies
) external onlyOwner {
    verifiers[_method] = _verifier;
    for (uint256 i = 0; i < _currencies.length; i++) {
        supportedCurrencies[_method][_currencies[i]] = true;
    }
}
Supported payment methods:
  • Venmo (USD)
  • PayPal (USD, EUR, GBP)
  • Wise (USD, EUR, GBP, SGD, etc.)
  • Zelle (USD)
  • CashApp (USD)
  • Revolut (USD, EUR, GBP)
  • MercadoPago (BRL, ARS)
  • Monzo (GBP)
Whitelists valid escrow implementations:
mapping(address => bool) public whitelistedEscrows;
bool public acceptAllEscrows;  // Emergency flag

function addEscrow(address _escrow) external onlyOwner {
    whitelistedEscrows[_escrow] = true;
}
This allows protocol upgrades without migrating all deposits.
Globally tracks used payment proofs:
mapping(bytes32 => bool) public usedNullifiers;

function addNullifier(bytes32 _nullifier) external onlyVerifier {
    if (usedNullifiers[_nullifier]) revert AlreadyNullified();
    usedNullifiers[_nullifier] = true;
}
Prevents double-spending across all deposits and orchestrators.
Manages approved post-fulfillment hooks:
mapping(address => bool) public whitelistedHooks;

function addHook(address _hook) external onlyOwner {
    whitelistedHooks[_hook] = true;
}
Example hooks:
  • Across Bridge Hook - Automatically bridge USDC to another chain
  • Swap Hook - Convert USDC to another token
  • Multi-recipient Hook - Split payment among multiple addresses
Authorizes relayers for gasless transactions:
mapping(address => bool) public whitelistedRelayers;

function isWhitelistedRelayer(address _relayer) external view returns (bool) {
    return whitelistedRelayers[_relayer];
}
Relayers can signal multiple intents simultaneously for better UX.

Protocol Viewer

A read-only contract for efficient state aggregation:
contract ProtocolViewer {
    function getDepositWithIntents(uint256 _depositId) 
        external 
        view 
        returns (
            IEscrow.Deposit memory deposit,
            bytes32[] memory intentHashes,
            IEscrow.Intent[] memory intents
        ) 
    {
        deposit = escrow.getDeposit(_depositId);
        intentHashes = escrow.getDepositIntentHashes(_depositId);
        
        intents = new IEscrow.Intent[](intentHashes.length);
        for (uint256 i = 0; i < intentHashes.length; i++) {
            intents[i] = escrow.getDepositIntent(_depositId, intentHashes[i]);
        }
    }
}
Use Protocol Viewer for frontend queries to reduce RPC calls and improve performance.

Complete User Flow

Here’s the end-to-end flow with all components:

Step-by-Step Breakdown

1

Maker Deposits Liquidity

Maker calls Escrow.createDeposit() with:
  • 1000 USDC
  • Accepted payment methods (Venmo, PayPal)
  • Min/max intent amounts (10-500 USDC)
  • Supported currencies and rates
Escrow locks funds and assigns a unique depositId.
2

Taker Signals Intent

Taker calls Orchestrator.signalIntent() with:
  • Target deposit and amount (100 USDC)
  • Payment method (Venmo)
  • Currency (USD) and rate (1:1)
  • Recipient address
Orchestrator:
  1. Validates all parameters
  2. Generates unique intent hash
  3. Calls Escrow.lockFunds() to reserve liquidity
  4. Stores intent parameters for verification
3

Taker Sends Fiat Payment

Taker sends $100 via Venmo to maker’s payee details.
This is a standard Venmo payment - no special protocol interaction required.
4

Payment Proof Generation

Taker obtains payment receipt and sends to attestation service:
  1. Attestation service verifies payment via zkTLS
  2. Extracts payment details (amount, currency, payee, timestamp)
  3. Creates EIP-712 typed data structure
  4. Signs with trusted witness keys
  5. Returns attestation to taker
5

Taker Fulfills Intent

Taker (or relayer) calls Orchestrator.fulfillIntent() with attestation.Orchestrator:
  1. Retrieves intent parameters
  2. Gets verifier from payment registry
  3. Calls UnifiedPaymentVerifier.verifyPayment()
Verifier:
  1. Validates EIP-712 signatures
  2. Checks payment details match intent
  3. Verifies timestamp within buffer
  4. Nullifies payment ID
  5. Returns verification result
6

Settlement & Distribution

If verification succeeds:
  1. Orchestrator calls Escrow.unlockAndTransfer()
  2. Escrow transfers 100 USDC to Orchestrator
  3. Orchestrator deducts 1% protocol fee (1 USDC)
  4. Orchestrator transfers 99 USDC to taker
  5. Intent is pruned from both contracts
If post-intent hook specified:
  • Funds go to hook contract instead
  • Hook executes custom logic (bridge, swap, etc.)

Security Considerations

Reentrancy Protection

All state-changing functions use OpenZeppelin’s ReentrancyGuard

Pausable Contracts

Emergency pause functionality preserves fund recovery options

Access Control

Role-based permissions via Ownable and custom modifiers

Signature Validation

EIP-712 typed data and EIP-1271 contract signatures

Nullifier System

Global prevention of double-spending payment proofs

Intent Expiration

Time-bounded locks prevent indefinite liquidity freezing

Pausable Functionality

Both Escrow and Orchestrator implement careful pause logic:
// PAUSED functions (for safety):
- createDeposit
- addFunds
- removeFunds
- signalIntent
- fulfillIntent

// ALWAYS AVAILABLE (for recovery):
- withdrawDeposit
- cancelIntent
- releaseFundsToPayer
- pruneExpiredIntents
Pausing does NOT prevent users from recovering their funds. Withdrawal and cancellation remain available.

Gas Optimization

The architecture includes several gas optimizations:
solidity: {
  version: "0.8.18",
  settings: {
    optimizer: { enabled: true, runs: 200 },
    viaIR: true  // Enables IR-based optimizer
  }
}
struct Deposit {
    address depositor;        // 20 bytes
    address delegate;         // 20 bytes
    IERC20 token;            // 20 bytes
    Range intentAmountRange; // 64 bytes
    bool acceptingIntents;   // 1 byte  
    // Packed into same slots where possible
}
Functions support array parameters to batch operations:
function addPaymentMethods(
    bytes32[] calldata _paymentMethods,
    DepositPaymentMethodData[] calldata _paymentMethodData,
    Currency[][] calldata _currencies
)
Protocol Viewer aggregates multiple reads:
function getDepositWithIntents(uint256 _depositId)
    external view
    returns (
        Deposit memory deposit,
        bytes32[] memory intentHashes,
        Intent[] memory intents
    )

Upgrade Path

The modular architecture supports upgrades without full migration:
1

Deploy New Components

Deploy updated contracts (e.g., OrchestratorV2, EscrowV2)
2

Register in System

Add new contracts to respective registries
3

Gradual Migration

  • Old deposits remain on old Escrow
  • New deposits use new Escrow
  • Both can coexist using registry pattern
4

Update References

Point Orchestrator to new Escrow via setEscrowRegistry()
The registry pattern allows multiple versions to coexist, enabling gradual migration without disrupting active trades.

Extension Points

The architecture provides several extension points:

Post Intent Hooks

Custom logic executed after intent fulfillment:
interface IPostIntentHook {
    function execute(
        IOrchestrator.Intent memory intent,
        uint256 amount,
        bytes memory data
    ) external;
}
Example: Across Bridge Hook automatically bridges USDC to another chain after fulfillment.

Custom Verifiers

New payment methods via IPaymentVerifier interface:
interface IPaymentVerifier {
    function verifyPayment(VerifyPaymentData calldata data)
        external
        returns (PaymentVerificationResult memory);
}

Delegate Management

Deposits can have delegates for automated management:
function setDelegate(uint256 _depositId, address _delegate) external;
Delegates can update rates, add currencies, and manage deposit config.

Next Steps

Contract API Reference

Detailed documentation for all contract interfaces

Integration Guide

Learn how to integrate ZKP2P into your application

Smart Contracts

Review core contract documentation and implementation details

Testing Guide

Code examples and testing patterns for integration

Build docs developers (and LLMs) love