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:
Monitor Off-Chain Payments : Watch for payments on Venmo, PayPal, Wise, etc.
Extract Payment Details : Parse amount, currency, timestamp, payment ID, etc.
Fetch Intent Data : Read intent parameters from Orchestrator
Calculate Release Amount : Apply conversion rate and handle partial fulfillment
Sign Attestation : Create EIP-712 signature over PaymentAttestation struct
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:
Verifier Replacement : Deploy new UnifiedPaymentVerifier, update PaymentVerifierRegistry
Attestation Verifier : Update via setAttestationVerifier() without redeploying main verifier
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.