diff --git a/contracts/EIP712Verifier.sol b/contracts/EIP712Verifier.sol new file mode 100644 index 0000000..6f9decb --- /dev/null +++ b/contracts/EIP712Verifier.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/** + * @title EIP712Verifier + * @notice Implements EIP-712 typed structured data signing for off-chain message validation + * @dev Provides secure off-chain signatures with replay protection for claims and verifications + */ +contract EIP712Verifier is EIP712 { + using ECDSA for bytes32; + + // ============ Type Hashes ============ + + bytes32 public constant CLAIM_SUBMISSION_TYPEHASH = keccak256( + "ClaimSubmission(address claimant,uint256 bountyId,bytes32 contentHash,uint256 nonce,uint256 deadline)" + ); + + bytes32 public constant VERIFICATION_INTENT_TYPEHASH = keccak256( + "VerificationIntent(address verifier,uint256 bountyId,bool approve,string reason,uint256 nonce,uint256 deadline)" + ); + + // ============ State ============ + + /// @notice Nonces for replay protection per address + mapping(address => uint256) public nonces; + + /// @notice Tracks used signatures to prevent replay + mapping(bytes32 => bool) public usedSignatures; + + // ============ Events ============ + + event ClaimSubmissionVerified( + address indexed claimant, + uint256 indexed bountyId, + bytes32 contentHash, + uint256 nonce + ); + + event VerificationIntentVerified( + address indexed verifier, + uint256 indexed bountyId, + bool approve, + uint256 nonce + ); + + // ============ Errors ============ + + error InvalidSignature(); + error SignatureExpired(); + error SignatureAlreadyUsed(); + error InvalidNonce(); + + // ============ Constructor ============ + + constructor() EIP712("TruthBounty", "1") {} + + // ============ External Functions ============ + + /** + * @notice Verifies a claim submission signature + * @param claimant The address making the claim + * @param bountyId The ID of the bounty being claimed + * @param contentHash Hash of the claim content + * @param deadline Signature expiration timestamp + * @param signature The EIP-712 signature + * @return True if signature is valid + */ + function verifyClaimSubmission( + address claimant, + uint256 bountyId, + bytes32 contentHash, + uint256 deadline, + bytes calldata signature + ) external returns (bool) { + if (block.timestamp > deadline) revert SignatureExpired(); + + uint256 currentNonce = nonces[claimant]; + + bytes32 structHash = keccak256(abi.encode( + CLAIM_SUBMISSION_TYPEHASH, + claimant, + bountyId, + contentHash, + currentNonce, + deadline + )); + + bytes32 digest = _hashTypedDataV4(structHash); + + if (usedSignatures[digest]) revert SignatureAlreadyUsed(); + + address signer = digest.recover(signature); + if (signer != claimant) revert InvalidSignature(); + + usedSignatures[digest] = true; + nonces[claimant] = currentNonce + 1; + + emit ClaimSubmissionVerified(claimant, bountyId, contentHash, currentNonce); + + return true; + } + + /** + * @notice Verifies a verification intent signature + * @param verifier The address of the verifier + * @param bountyId The ID of the bounty being verified + * @param approve Whether the verifier approves the claim + * @param reason The reason for the verification decision + * @param deadline Signature expiration timestamp + * @param signature The EIP-712 signature + * @return True if signature is valid + */ + function verifyVerificationIntent( + address verifier, + uint256 bountyId, + bool approve, + string calldata reason, + uint256 deadline, + bytes calldata signature + ) external returns (bool) { + if (block.timestamp > deadline) revert SignatureExpired(); + + uint256 currentNonce = nonces[verifier]; + + bytes32 structHash = keccak256(abi.encode( + VERIFICATION_INTENT_TYPEHASH, + verifier, + bountyId, + approve, + keccak256(bytes(reason)), + currentNonce, + deadline + )); + + bytes32 digest = _hashTypedDataV4(structHash); + + if (usedSignatures[digest]) revert SignatureAlreadyUsed(); + + address signer = digest.recover(signature); + if (signer != verifier) revert InvalidSignature(); + + usedSignatures[digest] = true; + nonces[verifier] = currentNonce + 1; + + emit VerificationIntentVerified(verifier, bountyId, approve, currentNonce); + + return true; + } + + /** + * @notice Returns the current nonce for an address + * @param account The address to query + * @return The current nonce + */ + function getNonce(address account) external view returns (uint256) { + return nonces[account]; + } + + /** + * @notice Returns the domain separator for this contract + * @return The EIP-712 domain separator + */ + function getDomainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @notice Computes the hash of a claim submission for off-chain signing + * @param claimant The address making the claim + * @param bountyId The ID of the bounty being claimed + * @param contentHash Hash of the claim content + * @param nonce The nonce for replay protection + * @param deadline Signature expiration timestamp + * @return The typed data hash to sign + */ + function getClaimSubmissionHash( + address claimant, + uint256 bountyId, + bytes32 contentHash, + uint256 nonce, + uint256 deadline + ) external view returns (bytes32) { + bytes32 structHash = keccak256(abi.encode( + CLAIM_SUBMISSION_TYPEHASH, + claimant, + bountyId, + contentHash, + nonce, + deadline + )); + return _hashTypedDataV4(structHash); + } + + /** + * @notice Computes the hash of a verification intent for off-chain signing + * @param verifier The address of the verifier + * @param bountyId The ID of the bounty being verified + * @param approve Whether the verifier approves + * @param reason The reason for the decision + * @param nonce The nonce for replay protection + * @param deadline Signature expiration timestamp + * @return The typed data hash to sign + */ + function getVerificationIntentHash( + address verifier, + uint256 bountyId, + bool approve, + string calldata reason, + uint256 nonce, + uint256 deadline + ) external view returns (bytes32) { + bytes32 structHash = keccak256(abi.encode( + VERIFICATION_INTENT_TYPEHASH, + verifier, + bountyId, + approve, + keccak256(bytes(reason)), + nonce, + deadline + )); + return _hashTypedDataV4(structHash); + } + + /** + * @notice Checks if a signature has been used + * @param signatureHash The hash of the signature to check + * @return True if the signature has been used + */ + function isSignatureUsed(bytes32 signatureHash) external view returns (bool) { + return usedSignatures[signatureHash]; + } +} diff --git a/test/EIP712Verifier.test.ts b/test/EIP712Verifier.test.ts new file mode 100644 index 0000000..3488891 --- /dev/null +++ b/test/EIP712Verifier.test.ts @@ -0,0 +1,417 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { EIP712Verifier } from "../typechain-types"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; + +describe("EIP712Verifier", function () { + let verifier: EIP712Verifier; + let owner: SignerWithAddress; + let claimant: SignerWithAddress; + let verifierSigner: SignerWithAddress; + + const DOMAIN_NAME = "TruthBounty"; + const DOMAIN_VERSION = "1"; + + beforeEach(async function () { + [owner, claimant, verifierSigner] = await ethers.getSigners(); + + const EIP712Verifier = await ethers.getContractFactory("EIP712Verifier"); + verifier = await EIP712Verifier.deploy(); + await verifier.waitForDeployment(); + }); + + describe("Domain Separator", function () { + it("should return a valid domain separator", async function () { + const domainSeparator = await verifier.getDomainSeparator(); + expect(domainSeparator).to.not.equal(ethers.ZeroHash); + }); + }); + + describe("Claim Submission Signing", function () { + it("should verify a valid claim submission signature", async function () { + const bountyId = 1n; + const contentHash = ethers.keccak256(ethers.toUtf8Bytes("claim content")); + const nonce = await verifier.getNonce(claimant.address); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour from now + + const domain = { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await verifier.getAddress(), + }; + + const types = { + ClaimSubmission: [ + { name: "claimant", type: "address" }, + { name: "bountyId", type: "uint256" }, + { name: "contentHash", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const value = { + claimant: claimant.address, + bountyId, + contentHash, + nonce, + deadline, + }; + + const signature = await claimant.signTypedData(domain, types, value); + + await expect( + verifier.verifyClaimSubmission( + claimant.address, + bountyId, + contentHash, + deadline, + signature + ) + ) + .to.emit(verifier, "ClaimSubmissionVerified") + .withArgs(claimant.address, bountyId, contentHash, nonce); + }); + + it("should reject an invalid signature", async function () { + const bountyId = 1n; + const contentHash = ethers.keccak256(ethers.toUtf8Bytes("claim content")); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + // Sign with wrong signer + const domain = { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await verifier.getAddress(), + }; + + const types = { + ClaimSubmission: [ + { name: "claimant", type: "address" }, + { name: "bountyId", type: "uint256" }, + { name: "contentHash", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const value = { + claimant: claimant.address, + bountyId, + contentHash, + nonce: 0n, + deadline, + }; + + // Sign with owner instead of claimant + const signature = await owner.signTypedData(domain, types, value); + + await expect( + verifier.verifyClaimSubmission( + claimant.address, + bountyId, + contentHash, + deadline, + signature + ) + ).to.be.revertedWithCustomError(verifier, "InvalidSignature"); + }); + + it("should reject expired signature", async function () { + const bountyId = 1n; + const contentHash = ethers.keccak256(ethers.toUtf8Bytes("claim content")); + const deadline = BigInt(Math.floor(Date.now() / 1000) - 3600); // 1 hour ago + + const domain = { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await verifier.getAddress(), + }; + + const types = { + ClaimSubmission: [ + { name: "claimant", type: "address" }, + { name: "bountyId", type: "uint256" }, + { name: "contentHash", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const value = { + claimant: claimant.address, + bountyId, + contentHash, + nonce: 0n, + deadline, + }; + + const signature = await claimant.signTypedData(domain, types, value); + + await expect( + verifier.verifyClaimSubmission( + claimant.address, + bountyId, + contentHash, + deadline, + signature + ) + ).to.be.revertedWithCustomError(verifier, "SignatureExpired"); + }); + + it("should reject replay attacks (same signature twice)", async function () { + const bountyId = 1n; + const contentHash = ethers.keccak256(ethers.toUtf8Bytes("claim content")); + const nonce = await verifier.getNonce(claimant.address); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const domain = { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await verifier.getAddress(), + }; + + const types = { + ClaimSubmission: [ + { name: "claimant", type: "address" }, + { name: "bountyId", type: "uint256" }, + { name: "contentHash", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const value = { + claimant: claimant.address, + bountyId, + contentHash, + nonce, + deadline, + }; + + const signature = await claimant.signTypedData(domain, types, value); + + // First verification should succeed + await verifier.verifyClaimSubmission( + claimant.address, + bountyId, + contentHash, + deadline, + signature + ); + + // Second verification with same signature should fail + await expect( + verifier.verifyClaimSubmission( + claimant.address, + bountyId, + contentHash, + deadline, + signature + ) + ).to.be.revertedWithCustomError(verifier, "SignatureAlreadyUsed"); + }); + + it("should increment nonce after verification", async function () { + const bountyId = 1n; + const contentHash = ethers.keccak256(ethers.toUtf8Bytes("claim content")); + const nonceBefore = await verifier.getNonce(claimant.address); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const domain = { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await verifier.getAddress(), + }; + + const types = { + ClaimSubmission: [ + { name: "claimant", type: "address" }, + { name: "bountyId", type: "uint256" }, + { name: "contentHash", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const value = { + claimant: claimant.address, + bountyId, + contentHash, + nonce: nonceBefore, + deadline, + }; + + const signature = await claimant.signTypedData(domain, types, value); + + await verifier.verifyClaimSubmission( + claimant.address, + bountyId, + contentHash, + deadline, + signature + ); + + const nonceAfter = await verifier.getNonce(claimant.address); + expect(nonceAfter).to.equal(nonceBefore + 1n); + }); + }); + + describe("Verification Intent Signing", function () { + it("should verify a valid verification intent signature", async function () { + const bountyId = 1n; + const approve = true; + const reason = "Valid claim with sufficient evidence"; + const nonce = await verifier.getNonce(verifierSigner.address); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const domain = { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await verifier.getAddress(), + }; + + const types = { + VerificationIntent: [ + { name: "verifier", type: "address" }, + { name: "bountyId", type: "uint256" }, + { name: "approve", type: "bool" }, + { name: "reason", type: "string" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const value = { + verifier: verifierSigner.address, + bountyId, + approve, + reason, + nonce, + deadline, + }; + + const signature = await verifierSigner.signTypedData(domain, types, value); + + await expect( + verifier.verifyVerificationIntent( + verifierSigner.address, + bountyId, + approve, + reason, + deadline, + signature + ) + ) + .to.emit(verifier, "VerificationIntentVerified") + .withArgs(verifierSigner.address, bountyId, approve, nonce); + }); + + it("should reject invalid verification intent signature", async function () { + const bountyId = 1n; + const approve = true; + const reason = "Valid claim"; + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const domain = { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await verifier.getAddress(), + }; + + const types = { + VerificationIntent: [ + { name: "verifier", type: "address" }, + { name: "bountyId", type: "uint256" }, + { name: "approve", type: "bool" }, + { name: "reason", type: "string" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const value = { + verifier: verifierSigner.address, + bountyId, + approve, + reason, + nonce: 0n, + deadline, + }; + + // Sign with wrong signer + const signature = await owner.signTypedData(domain, types, value); + + await expect( + verifier.verifyVerificationIntent( + verifierSigner.address, + bountyId, + approve, + reason, + deadline, + signature + ) + ).to.be.revertedWithCustomError(verifier, "InvalidSignature"); + }); + }); + + describe("Hash Generation", function () { + it("should generate consistent claim submission hashes", async function () { + const claimantAddr = claimant.address; + const bountyId = 1n; + const contentHash = ethers.keccak256(ethers.toUtf8Bytes("test")); + const nonce = 0n; + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const hash1 = await verifier.getClaimSubmissionHash( + claimantAddr, + bountyId, + contentHash, + nonce, + deadline + ); + + const hash2 = await verifier.getClaimSubmissionHash( + claimantAddr, + bountyId, + contentHash, + nonce, + deadline + ); + + expect(hash1).to.equal(hash2); + }); + + it("should generate different hashes for different inputs", async function () { + const claimantAddr = claimant.address; + const contentHash = ethers.keccak256(ethers.toUtf8Bytes("test")); + const nonce = 0n; + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const hash1 = await verifier.getClaimSubmissionHash( + claimantAddr, + 1n, + contentHash, + nonce, + deadline + ); + + const hash2 = await verifier.getClaimSubmissionHash( + claimantAddr, + 2n, + contentHash, + nonce, + deadline + ); + + expect(hash1).to.not.equal(hash2); + }); + }); +});