Skip to main content

Overview

The Payment Verification System validates that buyers have completed off-chain fiat payments before releasing on-chain assets. The zkp2p protocol uses a unified verifier architecture that supports multiple payment methods (Venmo, PayPal, Wise, etc.) through a single configurable contract.
The system replaced individual payment verifiers (VenmoVerifier, PayPalVerifier, etc.) with the UnifiedPaymentVerifier for easier maintenance and upgrades.

Architecture Overview

Key Components

1. UnifiedPaymentVerifier

The main verifier contract that validates payment attestations (located at contracts/unifiedVerifier/UnifiedPaymentVerifier.sol). Responsibilities:
  • Decode payment attestations
  • Validate intent snapshots against on-chain intent data
  • Verify EIP-712 attestation signatures via AttestationVerifier
  • Nullify payments to prevent double-spending
  • Calculate release amounts (supporting partial fulfillment)
  • Emit payment details for off-chain reconciliation

2. AttestationVerifier

Pluggable verifier for attestation signatures from off-chain services (witnesses, TEE, etc.). Interface (from contracts/interfaces/IAttestationVerifier.sol:16):
function verify(
    bytes32 _digest,           // EIP-712 formatted message digest
    bytes[] calldata _sigs,    // Array of signatures from attestors
    bytes calldata _data       // Verification data (attestor identities, hints)
) external view returns (bool isValid);

3. NullifierRegistry

Prevents double-spending by tracking used payment IDs (from contracts/registries/NullifierRegistry.sol). Key Functions:
function addNullifier(bytes32 _nullifier) external onlyWriter;
function isNullified(bytes32 _nullifier) external view returns (bool);
Only addresses with write permissions (payment verifiers) can add nullifiers. This prevents griefing attacks.

Payment Verification Flow

Data Structures

PaymentAttestation

The top-level structure submitted as payment proof (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:85):
struct PaymentAttestation {
    bytes32 intentHash;       // Binds the payment to the intent on Orchestrator
    uint256 releaseAmount;    // Final token amount to release on-chain after FX
    bytes32 dataHash;         // Hash of the additional data to verify integrity
    bytes[] signatures;       // Array of signatures from witnesses
    bytes data;               // Data for verification (PaymentDetails + IntentSnapshot)
    bytes metadata;           // Additional metadata; isn't signed by the witnesses
}

PaymentDetails

Detailed information about the off-chain payment (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:65):
struct PaymentDetails {
    bytes32 method;           // Payment method hash (e.g., keccak256("venmo"))
    bytes32 payeeId;          // Payment recipient ID (hashed to preserve privacy)
    uint256 amount;           // Payment amount in smallest currency unit (cents)
    bytes32 currency;         // Payment currency hash (e.g., keccak256("USD"))
    uint256 timestamp;        // Payment timestamp in UTC milliseconds
    bytes32 paymentId;        // Hashed payment identifier from the service
}
All sensitive fields (payeeId, paymentId) are hashed before going on-chain to preserve privacy.

IntentSnapshot

Snapshot of intent parameters at signal time (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:74):
struct IntentSnapshot {
    bytes32 intentHash;
    uint256 amount;
    bytes32 paymentMethod;
    bytes32 fiatCurrency;
    bytes32 payeeDetails;
    uint256 conversionRate;
    uint256 signalTimestamp;
    uint256 timestampBuffer;      // Max allowed time between payment and fulfillment
}

Verification Process

Step 1: Decode Attestation

PaymentAttestation memory attestation = _decodeAttestation(_verifyPaymentData.paymentProof);
The payment proof is ABI-decoded into the PaymentAttestation struct.

Step 2: Decode Payload

(PaymentDetails memory paymentDetails, IntentSnapshot memory intentSnapshot) = 
    _decodeAttestationPayload(attestation.data);
The attestation.data field contains both payment details and intent snapshot.

Step 3: Validate Payment Method

require(isPaymentMethod[paymentDetails.method], "UPV: Invalid payment method");
Ensures the payment method is registered with the verifier.

Step 4: Validate Intent Snapshot

The verifier reads the intent from the Orchestrator and validates every field (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:220):
function _validateIntentSnapshot(
    bytes32 intentHash,
    IntentSnapshot memory snapshot
) internal view {
    require(snapshot.intentHash == intentHash, "UPV: Snapshot hash mismatch");
    
    IOrchestrator.Intent memory intent = IOrchestrator(msg.sender).getIntent(intentHash);
    require(snapshot.payeeDetails == intent.payeeId, "UPV: Snapshot payee mismatch");
    require(snapshot.amount == intent.amount, "UPV: Snapshot amount mismatch");
    require(snapshot.paymentMethod == intent.paymentMethod, "UPV: Snapshot method mismatch");
    require(snapshot.fiatCurrency == intent.fiatCurrency, "UPV: Snapshot currency mismatch");
    require(snapshot.conversionRate == intent.conversionRate, "UPV: Snapshot rate mismatch");
    require(snapshot.signalTimestamp == intent.timestamp, "UPV: Snapshot timestamp mismatch");
    require(snapshot.timestampBuffer <= MAX_TIMESTAMP_BUFFER, "UPV: Timestamp buffer exceeds maximum");
}
Why validate the snapshot? This ensures the attestation service is verifying the payment against the exact same intent parameters that were locked on-chain. Any mismatch indicates tampering.

Step 5: Verify Attestation

The verifier constructs an EIP-712 digest and validates signatures (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:183):
function _verifyAttestation(PaymentAttestation memory attestation) internal view returns (bool) {
    // Construct EIP-712 struct hash
    bytes32 structHash = keccak256(
        abi.encode(
            PAYMENT_ATTESTATION_TYPEHASH,
            attestation.intentHash,
            attestation.releaseAmount,
            attestation.dataHash
        )
    );
    
    // Construct EIP-712 digest
    bytes32 digest = keccak256(
        abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            structHash
        )
    );
    
    // Verify data integrity
    require(
        keccak256(attestation.data) == attestation.dataHash,
        "UPV: Data hash mismatch"
    );
    
    // Verify signatures via AttestationVerifier
    bool isValid = attestationVerifier.verify(
        digest, 
        attestation.signatures,
        attestation.data
    );
    
    return isValid;
}

EIP-712 Domain

The domain separator is computed at deployment (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:106):
DOMAIN_SEPARATOR = keccak256(
    abi.encode(
        DOMAIN_TYPEHASH,
        keccak256(bytes("UnifiedPaymentVerifier")), // name
        keccak256(bytes("1")),                      // version
        block.chainid,                              // chainId
        address(this)                               // verifyingContract
    )
);

EIP-712 Type Hash

bytes32 private constant PAYMENT_ATTESTATION_TYPEHASH = keccak256(
    "PaymentAttestation(bytes32 intentHash,uint256 releaseAmount,bytes32 dataHash)"
);

Step 6: Nullify Payment

To prevent double-spending, the payment ID is nullified (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:242):
function _nullifyPayment(bytes32 paymentMethod, bytes32 paymentId) internal {
    bytes32 nullifier = keccak256(abi.encodePacked(paymentMethod, paymentId));
    _validateAndAddNullifier(nullifier);
}
The nullifier includes both payment method and payment ID to prevent collisions (e.g., Venmo transaction #123 vs PayPal transaction #123).

Step 7: Calculate Release Amount

The release amount can be capped to the intent amount (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:250):
function _calculateReleaseAmount(uint256 releaseAmount, uint256 intentAmount) 
    internal pure returns (uint256) 
{
    if (releaseAmount > intentAmount) {
        return intentAmount;
    }
    return releaseAmount;
}
This supports partial fulfillment where the buyer pays less than the full intent amount.

Step 8: Emit Payment Details

Payment details are emitted for off-chain reconciliation and indexing (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:260):
emit PaymentVerified(
    intentHash,
    paymentDetails.method,
    paymentDetails.currency,
    paymentDetails.amount,
    paymentDetails.timestamp,
    paymentDetails.paymentId,
    paymentDetails.payeeId
);

Step 9: Return Result

result = PaymentVerificationResult({
    success: true,
    intentHash: attestation.intentHash,
    releaseAmount: releaseAmount
});

Base Verifier Architecture

The BaseUnifiedPaymentVerifier (from contracts/unifiedVerifier/BaseUnifiedPaymentVerifier.sol) provides shared functionality:

Payment Method Management

function addPaymentMethod(bytes32 _paymentMethod) external onlyOwner
function removePaymentMethod(bytes32 _paymentMethod) external onlyOwner
Payment methods are identified by their keccak256 hash:
bytes32 venmoMethod = keccak256("venmo");
bytes32 paypalMethod = keccak256("paypal");

Attestation Verifier Management

function setAttestationVerifier(address _newVerifier) external onlyOwner
The attestation verifier can be upgraded without redeploying the payment verifier.

Orchestrator Authorization

Only whitelisted orchestrators can call verifyPayment() (from contracts/unifiedVerifier/BaseUnifiedPaymentVerifier.sol:48):
modifier onlyOrchestrator() {
    require(orchestratorRegistry.isOrchestrator(msg.sender), "Only orchestrator can call");
    _;
}

Nullifier Registry

The NullifierRegistry prevents double-spending of payments (from contracts/registries/NullifierRegistry.sol).

Write Permissions

Only addresses with write permissions can add nullifiers:
function addWritePermission(address _newWriter) external onlyOwner
function removeWritePermission(address _removedWriter) external onlyOwner
Typically, payment verifiers are granted write permissions.

Adding Nullifiers

function addNullifier(bytes32 _nullifier) external onlyWriter {
    require(!isNullified[_nullifier], "Nullifier already exists");
    isNullified[_nullifier] = true;
    emit NullifierAdded(_nullifier, msg.sender);
}
Irreversible: Once a nullifier is added, it cannot be removed. This is by design to prevent replay attacks.

Payment Method Registry

The PaymentVerifierRegistry (from contracts/registries/PaymentVerifierRegistry.sol) maps payment methods to verifiers and supported currencies.

Adding Payment Methods

function addPaymentMethod(
    bytes32 _paymentMethod,
    address _verifier,
    bytes32[] calldata _currencies
) external onlyOwner
Example:
registry.addPaymentMethod(
    keccak256("venmo"),
    0x123...UnifiedVerifier,
    [
        keccak256("USD")
    ]
);

Managing Currencies

function addCurrencies(bytes32 _paymentMethod, bytes32[] calldata _currencies) external onlyOwner
function removeCurrencies(bytes32 _paymentMethod, bytes32[] calldata _currencies) external onlyOwner

Querying

function getVerifier(bytes32 _paymentMethod) external view returns (address)
function isCurrency(bytes32 _paymentMethod, bytes32 _currencyCode) external view returns (bool)
function getCurrencies(bytes32 _paymentMethod) external view returns (bytes32[] memory)

Attestation Service Integration

The UnifiedPaymentVerifier is designed to work with off-chain attestation services that:
  1. Monitor Off-Chain Payments: Watch for payments on Venmo, PayPal, Wise, etc.
  2. Extract Payment Details: Parse amount, currency, timestamp, payment ID, etc.
  3. Fetch Intent Data: Read intent parameters from Orchestrator
  4. Calculate Release Amount: Apply conversion rate and handle partial fulfillment
  5. Sign Attestation: Create EIP-712 signature over PaymentAttestation struct
  6. Submit to Orchestrator: Anyone can call fulfillIntent() with the signed attestation

Attestation Service Types

TEE-based

Trusted Execution Environments (SGX, TDX) verify payments in hardware enclaves

Multi-Witness

Multiple independent attestors sign payment verification (threshold signatures)

ZK Proofs

Zero-knowledge proofs of email receipts or bank statements

Hybrid

Combination of TEE + ZK or TEE + multi-witness for added security

Security Considerations

Timestamp Buffer

The timestampBuffer in IntentSnapshot limits how old a payment can be relative to the intent signal time:
require(
    snapshot.timestampBuffer <= MAX_TIMESTAMP_BUFFER, 
    "UPV: Snapshot timestamp buffer exceeds maximum"
);
  • Max buffer: 48 hours (MAX_TIMESTAMP_BUFFER = 48 * 60 * 60 * 1000 at line 31)

Data Hash Integrity

The attestation includes a dataHash that must match the actual data:
require(
    keccak256(attestation.data) == attestation.dataHash,
    "UPV: Data hash mismatch"
);
This prevents attestors from signing one payload but submitting another.

Orchestrator-Only Access

The verifier can only be called by whitelisted orchestrators (from contracts/unifiedVerifier/UnifiedPaymentVerifier.sol:128):
function verifyPayment(VerifyPaymentData calldata _verifyPaymentData)
    external
    override
    onlyOrchestrator()
    returns (PaymentVerificationResult memory result)
This prevents griefing attacks where malicious actors nullify payment IDs.

Events

event PaymentVerified(
    bytes32 indexed intentHash,
    bytes32 indexed method,
    bytes32 indexed currency,
    uint256 amount,
    uint256 timestamp,
    bytes32 paymentId,
    bytes32 payeeId
);

event PaymentMethodAdded(bytes32 indexed paymentMethod);
event PaymentMethodRemoved(bytes32 indexed paymentMethod);
event AttestationVerifierUpdated(address indexed oldVerifier, address indexed newVerifier);

Upgradeability

The payment verification system is designed for easy upgrades:
  1. Verifier Replacement: Deploy new UnifiedPaymentVerifier, update PaymentVerifierRegistry
  2. Attestation Verifier: Update via setAttestationVerifier() without redeploying main verifier
  3. Payment Methods: Add/remove payment methods via registry without contract changes
No Critical State: The UnifiedPaymentVerifier holds no critical state (besides configuration). All intents and deposits live in Orchestrator and Escrow.

Build docs developers (and LLMs) love