Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions contracts/verifiers/OTPVerifier.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: MIT

import { BasePaymentVerifier } from "./BaseVerifiers/BasePaymentVerifier.sol";
import { INullifierRegistry } from "./nullifierRegistries/INullifierRegistry.sol";
import { IPaymentVerifier } from "./interfaces/IPaymentVerifier.sol";

pragma solidity ^0.8.18;

/**
* @title OTPVerifier
* @notice OTP verifier with deposit-specific hashing to prevent cross-deposit attacks
*
* Flow:
* 1. Depositor creates deposit with secret hash stored in DepositVerifierData.data
* 2. Anyone can signal intent on this deposit (1:1 withdrawal only)
* 3. To fulfill intent, user provides the OTP secret
* 4. Contract verifies secret with deposit-specific hashing and releases funds
*
* Security:
* - Uses deposit-specific hashing: hash(secret, payeeDetails)
* - Prevents cross-deposit attacks where same secret compromises multiple deposits
* - No nullifiers needed - escrow prevents double spending via intent removal
*/
contract OTPVerifier is IPaymentVerifier, BasePaymentVerifier {

/* ============ Events ============ */

event SecretRevealed(
bytes32 indexed secretHash,
bytes32 indexed intentHash,
address indexed fulfiller
);

/* ============ Constructor ============ */

constructor(
address _escrow,
INullifierRegistry _nullifierRegistry,
uint256 _timestampBuffer,
bytes32[] memory _currencies
) BasePaymentVerifier(
_escrow,
_nullifierRegistry,
_timestampBuffer,
_currencies
) {}

/* ============ External Functions ============ */

/**
* @notice Verifies OTP using deposit-specific hashing
* @param _verifyPaymentData Contains the OTP secret and verification context
* @return success Whether verification succeeded
* @return intentHash Intent hash extracted from payment proof
*/
function verifyPayment(
IPaymentVerifier.VerifyPaymentData calldata _verifyPaymentData
) external override returns (bool success, bytes32 intentHash) {
require(msg.sender == escrow, "Only escrow can call");

// Extract the secret and intent hash from payment proof
// Format: [providedSecret(32 bytes)][intentHash(32 bytes)]
(bytes32 providedSecret, bytes32 providedIntentHash) = abi.decode(
_verifyPaymentData.paymentProof,
(bytes32, bytes32)
);

// Extract stored secret hash from data field
bytes32 storedSecretHash = abi.decode(_verifyPaymentData.data, (bytes32));

// Compute deposit-specific hash: hash(secret, payeeDetails)
// This prevents cross-deposit attacks where knowing one secret compromises others
bytes32 computedHash = keccak256(abi.encodePacked(
providedSecret,
_verifyPaymentData.payeeDetails
));

require(computedHash == storedSecretHash, "Invalid OTP: secret does not match hash");

// Verify timing constraint
require(
block.timestamp >= _verifyPaymentData.intentTimestamp,
"Verification cannot happen before intent timestamp"
);

// Return the intent hash that was provided in the payment proof
// This follows the same pattern as other verifiers which extract intent hash from proof data
intentHash = providedIntentHash;

emit SecretRevealed(storedSecretHash, intentHash, tx.origin);

return (true, intentHash);
}

/* ============ Utility Functions ============ */

/**
* @notice Generate deposit-specific secret hash for deposit creation
* @param _secret The OTP secret (6-12 word passphrase)
* @param _payeeDetails Unique identifier for this deposit
* @return Deposit-specific hash that prevents cross-deposit attacks
*/
function generateSecretHash(bytes32 _secret, string memory _payeeDetails) external pure returns (bytes32) {
return keccak256(abi.encodePacked(_secret, _payeeDetails));
}

/**
* @notice Verify a secret against a deposit-specific hash
* @param _secret The secret to verify
* @param _payeeDetails The payee details used in original hashing
* @param _hash The expected deposit-specific hash
* @return Whether the secret matches the hash for this specific deposit
*/
function verifySecret(bytes32 _secret, string memory _payeeDetails, bytes32 _hash) external pure returns (bool) {
return keccak256(abi.encodePacked(_secret, _payeeDetails)) == _hash;
}
}
246 changes: 246 additions & 0 deletions test/verifiers/otpVerifier.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import "module-alias/register";

import { ethers } from "hardhat";
import { BigNumber } from "ethers";

import { Address } from "@utils/types";
import { Account } from "@utils/test/types";
import {
Escrow,
USDCMock,
NullifierRegistry,
OTPVerifier
} from "@utils/contracts";
import DeployHelper from "@utils/deploys";

import {
getWaffleExpect,
getAccounts,
addSnapshotBeforeRestoreAfterEach
} from "@utils/test/index";
import { ether, usdc, Blockchain } from "@utils/common";
import { ZERO, ONE, ADDRESS_ZERO } from "@utils/constants";
import { Currency, calculateIntentHash } from "@utils/protocolUtils";

const expect = getWaffleExpect();

const blockchain = new Blockchain(ethers.provider);

describe("OTPVerifier - Simple Integration Test", () => {
let owner: Account;
let depositor: Account;
let withdrawer: Account;

let escrow: Escrow;
let usdcToken: USDCMock;
let nullifierRegistry: NullifierRegistry;
let otpVerifier: OTPVerifier;

let deployer: DeployHelper;

// Test parameters
const depositAmount = usdc(100); // 100 USDC
const secret = ethers.utils.formatBytes32String("horse battery staple magic");
const payeeDetails = "alice-deposit-123";

addSnapshotBeforeRestoreAfterEach();

beforeEach(async () => {
[owner, depositor, withdrawer] = await getAccounts();
deployer = new DeployHelper(owner.wallet);

// Deploy USDC mock and mint tokens
usdcToken = await deployer.deployUSDCMock(ether(1000000), "USDC", "USDC");
await usdcToken.transfer(depositor.address, usdc(1000));

// Deploy Escrow
escrow = await deployer.deployEscrow(
owner.address,
BigNumber.from(86400), // 1 day intent expiration
ether(0.01), // 1% sustainability fee
owner.address, // fee recipient
ONE // chain ID
);

// Deploy Nullifier Registry
nullifierRegistry = await deployer.deployNullifierRegistry();

// Deploy OTP Verifier
otpVerifier = await deployer.deployOTPVerifier(
escrow.address,
nullifierRegistry.address,
BigNumber.from(300), // 5 min timestamp buffer
[Currency.USD] // supported currencies
);

// Add OTP verifier as whitelisted payment verifier
await escrow.addWhitelistedPaymentVerifier(otpVerifier.address, ZERO);

console.log("\n=== Contract Addresses ===");
console.log("Escrow:", escrow.address);
console.log("USDC:", usdcToken.address);
console.log("OTPVerifier:", otpVerifier.address);
console.log("NullifierRegistry:", nullifierRegistry.address);
});

describe("Full OTP Flow", () => {
it("Should complete entire OTP deposit and withdrawal flow", async () => {
console.log("\n=== Step 1: Generate OTP Secret Hash ===");

// Generate secret hash using deposit-specific hashing
const secretHash = await otpVerifier.generateSecretHash(secret, payeeDetails);
console.log("Secret:", ethers.utils.parseBytes32String(secret));
console.log("Payee Details:", payeeDetails);
console.log("Secret Hash:", secretHash);

console.log("\n=== Step 2: Create Deposit ===");

// Approve USDC transfer
await usdcToken.connect(depositor.wallet).approve(escrow.address, depositAmount);

// Create deposit with OTP verifier
await escrow.connect(depositor.wallet).createDeposit(
usdcToken.address,
depositAmount,
{ min: depositAmount, max: depositAmount }, // Force full withdrawal
[otpVerifier.address], // verifiers
[{
intentGatingService: ADDRESS_ZERO,
payeeDetails: payeeDetails,
data: ethers.utils.defaultAbiCoder.encode(["bytes32"], [secretHash])
}], // verifier data
[[{ code: Currency.USD, conversionRate: ether(1) }]] // currencies
);

const depositId = 0;
console.log("✓ Deposit created with ID:", depositId);

// Verify deposit state
const deposit = await escrow.deposits(depositId);
expect(deposit.amount).to.equal(depositAmount);
expect(deposit.remainingDeposits).to.equal(depositAmount);
console.log("✓ Deposit amount:", ethers.utils.formatUnits(deposit.amount, 6), "USDC");

console.log("\n=== Step 3: Signal Intent ===");

// Signal intent to withdraw full amount
const signalIntentTx = await escrow.connect(withdrawer.wallet).signalIntent(
depositId,
depositAmount,
withdrawer.address,
otpVerifier.address,
Currency.USD,
"0x" // empty gating service signature since we use ADDRESS_ZERO
);

// Get timestamp from the block where the intent was signaled
const signalIntentReceipt = await signalIntentTx.wait();
const signalBlock = await ethers.provider.getBlock(signalIntentReceipt.blockNumber);
const signalTimestamp = BigNumber.from(signalBlock.timestamp);

const calculatedIntentHash = calculateIntentHash(
withdrawer.address,
otpVerifier.address,
BigNumber.from(depositId),
signalTimestamp
);

// Get the actual intent hash stored in the escrow
const actualIntentHash = await escrow.accountIntent(withdrawer.address);

console.log("✓ Intent signaled");
console.log(" - Calculated intent hash:", calculatedIntentHash);
console.log(" - Actual intent hash: ", actualIntentHash);
console.log(" - Intent hashes match: ", calculatedIntentHash === actualIntentHash);

const intentHash = actualIntentHash; // Use the actual hash from the escrow

console.log("\n=== Step 4: Fulfill Intent (Provide OTP) ===");

// Encode the secret and intent hash as payment proof
// Format: [secret, intentHash] - following the same pattern as other verifiers
const paymentProof = ethers.utils.defaultAbiCoder.encode(
["bytes32", "bytes32"],
[secret, intentHash]
);

// Fulfill intent by providing the OTP secret and intent hash
await escrow.connect(withdrawer.wallet).fulfillIntent(paymentProof, intentHash);

console.log("✓ Intent fulfilled with OTP secret!");

console.log("\n=== Step 5: Verify Final State ===");

// Check that withdrawer received the tokens
const withdrawerBalance = await usdcToken.balanceOf(withdrawer.address);
expect(withdrawerBalance).to.be.gt(ZERO); // Should have received tokens minus fees
console.log("✓ Withdrawer received:", ethers.utils.formatUnits(withdrawerBalance, 6), "USDC");

// Check that deposit is consumed
const finalDeposit = await escrow.deposits(depositId);
expect(finalDeposit.remainingDeposits).to.equal(ZERO);
console.log("✓ Deposit fully consumed");

console.log("\n=== ✅ OTP Flow Complete! ===");
console.log("Summary:");
console.log("- Depositor locked 100 USDC with OTP secret");
console.log("- Withdrawer provided correct OTP and received USDC");
console.log("- No cross-deposit attack possible (deposit-specific hashing)");
console.log("- No nullifiers used (escrow handles double-spending)");
});

it("Should reject incorrect OTP secret", async () => {
console.log("\n=== Testing Incorrect OTP ===");

// Generate secret hash
const secretHash = await otpVerifier.generateSecretHash(secret, payeeDetails);

// Create deposit
await usdcToken.connect(depositor.wallet).approve(escrow.address, depositAmount);
await escrow.connect(depositor.wallet).createDeposit(
usdcToken.address,
depositAmount,
{ min: depositAmount, max: depositAmount },
[otpVerifier.address],
[{
intentGatingService: ADDRESS_ZERO,
payeeDetails: payeeDetails,
data: ethers.utils.defaultAbiCoder.encode(["bytes32"], [secretHash])
}],
[[{ code: Currency.USD, conversionRate: ether(1) }]]
);

// Signal intent
const signalIntentTx2 = await escrow.connect(withdrawer.wallet).signalIntent(
0, depositAmount, withdrawer.address,
otpVerifier.address, Currency.USD, "0x"
);

// Calculate intent hash properly
const signalIntentReceipt2 = await signalIntentTx2.wait();
const signalBlock2 = await ethers.provider.getBlock(signalIntentReceipt2.blockNumber);
const signalTimestamp2 = BigNumber.from(signalBlock2.timestamp);

const intentHash = calculateIntentHash(
withdrawer.address,
otpVerifier.address,
ZERO,
signalTimestamp2
);

// Try to fulfill with WRONG secret
const wrongSecret = ethers.utils.formatBytes32String("wrong secret");
const wrongPaymentProof = ethers.utils.defaultAbiCoder.encode(
["bytes32", "bytes32"],
[wrongSecret, intentHash]
);

// Should fail
await expect(
escrow.connect(withdrawer.wallet).fulfillIntent(wrongPaymentProof, intentHash)
).to.be.revertedWith("Invalid OTP: secret does not match hash");

console.log("✓ Correctly rejected wrong OTP secret");
});
});
});
3 changes: 2 additions & 1 deletion utils/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
ZelleBaseVerifier,
ZelleBoAReclaimVerifier,
ZelleCitiReclaimVerifier,
ZelleChaseReclaimVerifier
ZelleChaseReclaimVerifier,
OTPVerifier
} from "../typechain";
export { IEscrow } from "../typechain/contracts/Escrow";
Loading