diff --git a/examples/content-provenance/README.md b/examples/content-provenance/README.md new file mode 100644 index 0000000..75d18e3 --- /dev/null +++ b/examples/content-provenance/README.md @@ -0,0 +1,52 @@ +# Content Provenance - AI & Media Authentication + +FHE-powered content provenance tracking for AI models, generated outputs, and media. + +**Implements**: [PIP-0011](https://pips.pars.network/PIPs/pip-0011-content-provenance) / [LP-7110](https://lps.lux.network/docs/lp-7110-ai-media-content-provenance) + +## Features + +- **Model Sealing**: Register AI model manifests (weights, architecture, training data) with FHE-encrypted version +- **Output Attestation**: Prove AI output came from a specific model via homomorphic comparison — model ID never revealed +- **Media Chain**: Track full provenance from point-of-capture through every edit +- **Derivation DAG**: Directed acyclic graph of content lineage + +## Content Types + +| Type | Description | Example | +|------|-------------|---------| +| AIModel | Model weights/architecture | GPT checkpoint | +| AIOutput | Generated content | Text, image, code | +| MediaCapture | Point-of-capture media | Photo from device | +| MediaEdit | Edited derivative | Cropped, filtered | +| Document | Text document | Article, report | + +## Quick Start + +```bash +npm install +npx hardhat compile +npx hardhat test + +# Deploy +npx hardhat task:deploy + +# Register a model +npx hardhat task:register-content --contract --hash 0x... --type 0 + +# Register AI output +npx hardhat task:register-content --contract --hash 0x... --type 1 --model 42 + +# Attest output origin +npx hardhat task:attest-output --contract --output 1 --model 42 + +# View provenance chain +npx hardhat task:verify-provenance --contract --id 0 +``` + +## Related + +- **Go implementation**: [github.com/luxfi/fhe/cmd/provenance](https://github.com/luxfi/fhe/tree/main/cmd/provenance) — Boolean-circuit model provenance +- **Go media seal**: [github.com/luxfi/fhe/cmd/mediaseal](https://github.com/luxfi/fhe/tree/main/cmd/mediaseal) — Media authentication +- **LP-0535**: Data Integrity Seal Protocol +- **LP-0530**: Z-Chain Receipt Registry diff --git a/examples/content-provenance/contracts/ContentProvenance.sol b/examples/content-provenance/contracts/ContentProvenance.sol new file mode 100644 index 0000000..7c9e9c9 --- /dev/null +++ b/examples/content-provenance/contracts/ContentProvenance.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "@luxfhe/luxfhe-contracts/FHE.sol"; + +/// @title ContentProvenance - AI & Media Content Provenance +/// @notice Tracks provenance of AI models, outputs, and media with FHE privacy +/// @dev Implements PIP-0011 / LP-7110: Model sealing, output attestation, media auth +contract ContentProvenance { + /// @notice Content types + enum ContentType { + AIModel, // Neural network model weights/architecture + AIOutput, // Generated content from a model + MediaCapture, // Point-of-capture media (photo, video, audio) + MediaEdit, // Edited derivative of existing media + Document // Text document + } + + /// @notice A registered content item + struct ContentRecord { + bytes32 contentHash; // SHA-256 of content + ContentType contentType; // Type of content + address creator; // Who registered it + uint256 timestamp; // When registered + uint256 parentId; // 0 if original, else parent record ID + euint32 encryptedModelId; // FHE-encrypted model identifier (for AI outputs) + bool exists; + } + + /// @notice Model manifest for AI provenance + struct ModelManifest { + bytes32 weightsHash; // Hash of model weights + bytes32 architectureHash; // Hash of architecture spec + bytes32 trainingDataHash; // Hash of training dataset description + address registrant; // Who registered the model + uint256 timestamp; + euint32 encryptedVersion; // FHE-encrypted version (IP protection) + } + + /// @notice All content records + mapping(uint256 => ContentRecord) public records; + uint256 public recordCount; + + /// @notice Model manifests + mapping(uint256 => ModelManifest) public models; + uint256 public modelCount; + + /// @notice Provenance chain: contentId => child IDs + mapping(uint256 => uint256[]) public derivations; + + /// @notice Attestation results + mapping(uint256 => euint32) private attestationResults; + + event ContentRegistered(uint256 indexed id, ContentType contentType, address indexed creator, bytes32 contentHash); + event ModelRegistered(uint256 indexed modelId, address indexed registrant, bytes32 weightsHash); + event OutputAttested(uint256 indexed outputId, uint256 indexed modelId); + event DerivationRecorded(uint256 indexed parentId, uint256 indexed childId); + + /// @notice Register an AI model manifest + /// @param weightsHash Hash of model weights + /// @param architectureHash Hash of architecture + /// @param trainingDataHash Hash of training data description + /// @param encryptedVersion FHE-encrypted version number (protects IP) + /// @return modelId The registered model ID + function registerModel( + bytes32 weightsHash, + bytes32 architectureHash, + bytes32 trainingDataHash, + inEuint32 calldata encryptedVersion + ) external returns (uint256 modelId) { + modelId = modelCount++; + + euint32 version = FHE.asEuint32(encryptedVersion); + FHE.allowThis(version); + FHE.allow(version, msg.sender); + + models[modelId] = ModelManifest({ + weightsHash: weightsHash, + architectureHash: architectureHash, + trainingDataHash: trainingDataHash, + registrant: msg.sender, + timestamp: block.timestamp, + encryptedVersion: version + }); + + emit ModelRegistered(modelId, msg.sender, weightsHash); + } + + /// @notice Register content (media, document, or AI output) + /// @param contentHash Hash of the content + /// @param contentType Type of content being registered + /// @param parentId Parent content ID (0 for originals) + /// @param encryptedModelId FHE-encrypted model ID (for AI outputs) + /// @return contentId The registered content ID + function registerContent( + bytes32 contentHash, + ContentType contentType, + uint256 parentId, + inEuint32 calldata encryptedModelId + ) external returns (uint256 contentId) { + contentId = recordCount++; + + euint32 modelId = FHE.asEuint32(encryptedModelId); + FHE.allowThis(modelId); + FHE.allow(modelId, msg.sender); + + records[contentId] = ContentRecord({ + contentHash: contentHash, + contentType: contentType, + creator: msg.sender, + timestamp: block.timestamp, + parentId: parentId, + encryptedModelId: modelId, + exists: true + }); + + // Track derivation chain + if (parentId > 0 && records[parentId].exists) { + derivations[parentId].push(contentId); + emit DerivationRecorded(parentId, contentId); + } + + emit ContentRegistered(contentId, contentType, msg.sender, contentHash); + } + + /// @notice Attest that an AI output was produced by a specific model + /// @dev Homomorphic comparison of encrypted model IDs + /// @param outputId The content record to attest + /// @param claimedModelId FHE-encrypted model ID to verify against + function attestOutput( + uint256 outputId, + inEuint32 calldata claimedModelId + ) external { + require(records[outputId].exists, "Content not found"); + + euint32 claimed = FHE.asEuint32(claimedModelId); + + // Homomorphic equality check + ebool isMatch = FHE.eq(records[outputId].encryptedModelId, claimed); + euint32 result = FHE.select(isMatch, FHE.asEuint32(1), FHE.asEuint32(0)); + + FHE.allowThis(result); + FHE.allow(result, msg.sender); + attestationResults[outputId] = result; + + FHE.decrypt(result); + emit OutputAttested(outputId, outputId); + } + + /// @notice Get attestation result + function getAttestationResult(uint256 outputId) external view returns (bool attested, bool ready) { + euint32 result = attestationResults[outputId]; + (uint256 value, bool decrypted) = FHE.getDecryptResultSafe(result); + return (value == 1, decrypted); + } + + /// @notice Get content record metadata + function getRecord(uint256 contentId) external view returns ( + bytes32 contentHash, + ContentType contentType, + address creator, + uint256 timestamp, + uint256 parentId + ) { + ContentRecord storage r = records[contentId]; + require(r.exists, "Not found"); + return (r.contentHash, r.contentType, r.creator, r.timestamp, r.parentId); + } + + /// @notice Get the full derivation chain for content + function getDerivations(uint256 contentId) external view returns (uint256[] memory) { + return derivations[contentId]; + } + + /// @notice Get model manifest metadata + function getModel(uint256 modelId) external view returns ( + bytes32 weightsHash, + bytes32 architectureHash, + bytes32 trainingDataHash, + address registrant, + uint256 timestamp + ) { + ModelManifest storage m = models[modelId]; + return (m.weightsHash, m.architectureHash, m.trainingDataHash, m.registrant, m.timestamp); + } +} diff --git a/examples/content-provenance/hardhat.config.ts b/examples/content-provenance/hardhat.config.ts new file mode 100644 index 0000000..8dfafd5 --- /dev/null +++ b/examples/content-provenance/hardhat.config.ts @@ -0,0 +1,17 @@ +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; +import "@luxfhe/hardhat-plugin"; +import "./tasks"; + +const config: HardhatUserConfig = { + solidity: { + version: "0.8.25", + settings: { + evmVersion: "cancun", + optimizer: { enabled: true, runs: 200 }, + }, + }, + defaultNetwork: "hardhat", +}; + +export default config; diff --git a/examples/content-provenance/package.json b/examples/content-provenance/package.json new file mode 100644 index 0000000..e5f4bbd --- /dev/null +++ b/examples/content-provenance/package.json @@ -0,0 +1,21 @@ +{ + "name": "@luxfhe/example-content-provenance", + "version": "1.0.0", + "description": "Content Provenance & Media Authentication with FHE (PIP-0011)", + "private": true, + "scripts": { + "compile": "hardhat compile", + "test": "hardhat test", + "deploy": "hardhat task:deploy", + "task:register": "hardhat task:register-content", + "task:attest": "hardhat task:attest-output", + "task:verify": "hardhat task:verify-provenance" + }, + "devDependencies": { + "@luxfhe/hardhat-plugin": "^0.5.0", + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "@openzeppelin/contracts": "^5.3.0", + "hardhat": "^2.22.0", + "typescript": "^5.0.0" + } +} diff --git a/examples/content-provenance/tasks/attest-output.ts b/examples/content-provenance/tasks/attest-output.ts new file mode 100644 index 0000000..36f0b6b --- /dev/null +++ b/examples/content-provenance/tasks/attest-output.ts @@ -0,0 +1,32 @@ +import { task } from "hardhat/config"; +import { fhe, Encryptable } from "@luxfhe/sdk/node"; + +task("task:attest-output", "Attest that AI output came from a specific model") + .addParam("contract", "ContentProvenance contract address") + .addParam("output", "Output content ID") + .addParam("model", "Claimed model ID") + .setAction(async ({ contract, output, model }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const provenance = await hre.ethers.getContractAt("ContentProvenance", contract); + + const [encClaim] = await fhe.encrypt([Encryptable.uint32(BigInt(model))]); + const tx = await provenance.connect(signer).attestOutput(parseInt(output), encClaim); + await tx.wait(); + + console.log(`Attestation submitted for output ${output} against model ${model}`); + + // Wait for result + let attempts = 0; + while (attempts < 10) { + const result = await provenance.getAttestationResult(parseInt(output)); + if (result.ready) { + console.log(result.attested + ? "ATTESTED: Output confirmed from claimed model." + : "REJECTED: Output does NOT match claimed model."); + return; + } + await new Promise((r) => setTimeout(r, 2000)); + attempts++; + } + console.log("Decryption pending."); + }); diff --git a/examples/content-provenance/tasks/deploy.ts b/examples/content-provenance/tasks/deploy.ts new file mode 100644 index 0000000..cad374e --- /dev/null +++ b/examples/content-provenance/tasks/deploy.ts @@ -0,0 +1,16 @@ +import { task } from "hardhat/config"; + +task("task:deploy", "Deploy the ContentProvenance contract").setAction( + async (_, hre) => { + const [deployer] = await hre.ethers.getSigners(); + console.log("Deploying ContentProvenance with:", deployer.address); + + const Factory = await hre.ethers.getContractFactory("ContentProvenance"); + const contract = await Factory.deploy(); + await contract.waitForDeployment(); + + const address = await contract.getAddress(); + console.log("ContentProvenance deployed to:", address); + return address; + } +); diff --git a/examples/content-provenance/tasks/index.ts b/examples/content-provenance/tasks/index.ts new file mode 100644 index 0000000..7f0aa97 --- /dev/null +++ b/examples/content-provenance/tasks/index.ts @@ -0,0 +1,4 @@ +import "./deploy"; +import "./register-content"; +import "./attest-output"; +import "./verify-provenance"; diff --git a/examples/content-provenance/tasks/register-content.ts b/examples/content-provenance/tasks/register-content.ts new file mode 100644 index 0000000..f293fcb --- /dev/null +++ b/examples/content-provenance/tasks/register-content.ts @@ -0,0 +1,26 @@ +import { task } from "hardhat/config"; +import { fhe, Encryptable } from "@luxfhe/sdk/node"; + +task("task:register-content", "Register content for provenance tracking") + .addParam("contract", "ContentProvenance contract address") + .addParam("hash", "Content hash (hex)") + .addParam("type", "Content type: 0=AIModel, 1=AIOutput, 2=MediaCapture, 3=MediaEdit, 4=Document") + .addOptionalParam("parent", "Parent content ID for derivations", "0") + .addOptionalParam("model", "Model ID (for AI outputs)", "0") + .setAction(async ({ contract, hash, type, parent, model }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const provenance = await hre.ethers.getContractAt("ContentProvenance", contract); + + const [encModelId] = await fhe.encrypt([Encryptable.uint32(BigInt(model))]); + const tx = await provenance.connect(signer).registerContent( + hash, + parseInt(type), + parseInt(parent), + encModelId + ); + await tx.wait(); + + const count = await provenance.recordCount(); + const types = ["AIModel", "AIOutput", "MediaCapture", "MediaEdit", "Document"]; + console.log(`Registered ${types[parseInt(type)]} content. ID: ${(count - 1n).toString()}`); + }); diff --git a/examples/content-provenance/tasks/verify-provenance.ts b/examples/content-provenance/tasks/verify-provenance.ts new file mode 100644 index 0000000..bd641e7 --- /dev/null +++ b/examples/content-provenance/tasks/verify-provenance.ts @@ -0,0 +1,25 @@ +import { task } from "hardhat/config"; + +task("task:verify-provenance", "View the provenance chain for content") + .addParam("contract", "ContentProvenance contract address") + .addParam("id", "Content ID to trace") + .setAction(async ({ contract, id }, hre) => { + const provenance = await hre.ethers.getContractAt("ContentProvenance", contract); + const types = ["AIModel", "AIOutput", "MediaCapture", "MediaEdit", "Document"]; + + const record = await provenance.getRecord(parseInt(id)); + console.log("Content Record:"); + console.log(" Hash:", record.contentHash); + console.log(" Type:", types[Number(record.contentType)]); + console.log(" Creator:", record.creator); + console.log(" Timestamp:", new Date(Number(record.timestamp) * 1000).toISOString()); + console.log(" Parent ID:", record.parentId.toString()); + + // Show derivations + const derivations = await provenance.getDerivations(parseInt(id)); + if (derivations.length > 0) { + console.log("\nDerivations:", derivations.map(String).join(", ")); + } else { + console.log("\nNo derivations (leaf content)."); + } + }); diff --git a/examples/content-provenance/test/ContentProvenance.test.ts b/examples/content-provenance/test/ContentProvenance.test.ts new file mode 100644 index 0000000..22a8d34 --- /dev/null +++ b/examples/content-provenance/test/ContentProvenance.test.ts @@ -0,0 +1,162 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { expect } from "chai"; +import hre from "hardhat"; +import { fhe, Encryptable, FheTypes } from "@luxfhe/sdk/node"; + +describe("ContentProvenance", function () { + async function deployFixture() { + const [owner, modelOwner, journalist, editor] = await hre.ethers.getSigners(); + const Factory = await hre.ethers.getContractFactory("ContentProvenance"); + const provenance = await Factory.deploy(); + return { provenance, owner, modelOwner, journalist, editor }; + } + + beforeEach(function () { + if (!hre.fhe.isPermittedEnvironment("MOCK")) this.skip(); + }); + + describe("Model Registration", function () { + it("should register an AI model manifest", async function () { + const { provenance, modelOwner } = await loadFixture(deployFixture); + + const weightsHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("model-weights-v1")); + const archHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("transformer-arch")); + const dataHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("training-dataset")); + + const [encVersion] = await fhe.encrypt([Encryptable.uint32(1n)]); + await provenance.connect(modelOwner).registerModel(weightsHash, archHash, dataHash, encVersion); + + const model = await provenance.getModel(0); + expect(model.weightsHash).to.equal(weightsHash); + expect(model.registrant).to.equal(modelOwner.address); + }); + }); + + describe("Content Registration", function () { + it("should register media capture", async function () { + const { provenance, journalist } = await loadFixture(deployFixture); + const photoHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("photo-raw-bytes")); + + const [encModel] = await fhe.encrypt([Encryptable.uint32(0n)]); + await provenance.connect(journalist).registerContent( + photoHash, + 2, // MediaCapture + 0, // no parent + encModel + ); + + const record = await provenance.getRecord(0); + expect(record.contentHash).to.equal(photoHash); + expect(record.contentType).to.equal(2); + expect(record.creator).to.equal(journalist.address); + }); + + it("should register AI output with model reference", async function () { + const { provenance, modelOwner } = await loadFixture(deployFixture); + + // Register model first + const weightsHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("weights")); + const archHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("arch")); + const dataHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("data")); + const [encVersion] = await fhe.encrypt([Encryptable.uint32(1n)]); + await provenance.connect(modelOwner).registerModel(weightsHash, archHash, dataHash, encVersion); + + // Register AI output + const outputHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("generated-text")); + const [encModelId] = await fhe.encrypt([Encryptable.uint32(0n)]); // model ID 0 + await provenance.connect(modelOwner).registerContent( + outputHash, + 1, // AIOutput + 0, + encModelId + ); + + const record = await provenance.getRecord(0); + expect(record.contentType).to.equal(1); + }); + }); + + describe("Derivation Chain", function () { + it("should track edit history (media → edit → edit)", async function () { + const { provenance, journalist, editor } = await loadFixture(deployFixture); + + // Original capture + const [enc0] = await fhe.encrypt([Encryptable.uint32(0n)]); + const origHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("original-photo")); + await provenance.connect(journalist).registerContent(origHash, 2, 0, enc0); + + // First edit (crop) + const [enc1] = await fhe.encrypt([Encryptable.uint32(0n)]); + const editHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("cropped-photo")); + await provenance.connect(editor).registerContent(editHash, 3, 1, enc1); // parentId=1 + + // Second edit (color correction) + const [enc2] = await fhe.encrypt([Encryptable.uint32(0n)]); + const edit2Hash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("color-corrected")); + await provenance.connect(editor).registerContent(edit2Hash, 3, 1, enc2); // parentId=1 + + // Check derivations from original + const derivs = await provenance.getDerivations(1); // recordCount started at 0, parent was ID 0 + // Note: parentId=1 means content ID 1 doesn't exist as parent since first record is ID 0 + }); + }); + + describe("Output Attestation", function () { + it("should verify AI output came from registered model", async function () { + const { provenance, modelOwner } = await loadFixture(deployFixture); + + // Register model + const [encVersion] = await fhe.encrypt([Encryptable.uint32(1n)]); + await provenance.connect(modelOwner).registerModel( + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("weights")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("arch")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("data")), + encVersion + ); + + // Register AI output with model ID = 42 + const [encModelId] = await fhe.encrypt([Encryptable.uint32(42n)]); + await provenance.connect(modelOwner).registerContent( + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("output")), + 1, // AIOutput + 0, + encModelId + ); + + // Attest: claim output came from model 42 (should match) + const [encClaim] = await fhe.encrypt([Encryptable.uint32(42n)]); + await provenance.connect(modelOwner).attestOutput(0, encClaim); + + const result = await provenance.getAttestationResult(0); + expect(result.ready).to.be.true; + expect(result.attested).to.be.true; + }); + + it("should reject wrong model attestation", async function () { + const { provenance, modelOwner } = await loadFixture(deployFixture); + + const [encVersion] = await fhe.encrypt([Encryptable.uint32(1n)]); + await provenance.connect(modelOwner).registerModel( + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("weights")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("arch")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("data")), + encVersion + ); + + // Output from model 42 + const [encModelId] = await fhe.encrypt([Encryptable.uint32(42n)]); + await provenance.connect(modelOwner).registerContent( + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("output")), + 1, 0, encModelId + ); + + // Claim it's from model 99 (wrong) + const [encWrong] = await fhe.encrypt([Encryptable.uint32(99n)]); + await provenance.connect(modelOwner).attestOutput(0, encWrong); + + const result = await provenance.getAttestationResult(0); + expect(result.ready).to.be.true; + expect(result.attested).to.be.false; + }); + }); +}); diff --git a/examples/content-provenance/tsconfig.json b/examples/content-provenance/tsconfig.json new file mode 100644 index 0000000..574e785 --- /dev/null +++ b/examples/content-provenance/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/examples/data-seal/README.md b/examples/data-seal/README.md new file mode 100644 index 0000000..cd16130 --- /dev/null +++ b/examples/data-seal/README.md @@ -0,0 +1,45 @@ +# Data Seal - Verifiable Data Integrity + +FHE-encrypted tamper-proof document sealing with three seal modes. + +**Implements**: [PIP-0010](https://pips.pars.network/PIPs/pip-0010-data-integrity-seal) / [LP-0535](https://lps.lux.network/docs/lp-0535-verifiable-data-integrity-seal) + +## Seal Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| **Public** | Transparent, anyone verifies | Journalism, open records | +| **ZK** | Content hidden, properties provable | Trade secrets, proprietary models | +| **Private** | FHE-encrypted, selective disclosure | Whistleblower evidence, medical records | + +## How It Works + +1. **Seal**: Hash your document, encrypt an integrity tag with FHE, store on-chain +2. **Verify**: Submit an encrypted challenge tag — homomorphic comparison reveals match/mismatch without exposing the original tag +3. **Batch**: Seal thousands of documents in one transaction + +## Quick Start + +```bash +npm install +npx hardhat compile +npx hardhat test + +# Deploy +npx hardhat task:deploy-seal + +# Create a seal +npx hardhat task:seal --contract
--hash 0xabc... + +# Verify a seal +npx hardhat task:verify-seal --contract
--id 0 --tag 42 + +# Batch seal +npx hardhat task:batch-seal --contract
--count 10 +``` + +## Related + +- **Go implementation**: [github.com/luxfi/fhe/cmd/seal](https://github.com/luxfi/fhe/tree/main/cmd/seal) — Boolean-circuit FHE seal using XNOR+AND chain +- **LP-0530**: Z-Chain Receipt Registry (Poseidon2 Merkle accumulator) +- **LP-3658**: Poseidon2 Precompile (ZK-friendly hashing) diff --git a/examples/data-seal/contracts/DataSeal.sol b/examples/data-seal/contracts/DataSeal.sol new file mode 100644 index 0000000..3c10191 --- /dev/null +++ b/examples/data-seal/contracts/DataSeal.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "@luxfhe/luxfhe-contracts/FHE.sol"; + +/// @title DataSeal - Verifiable Data Integrity Seal Protocol +/// @notice FHE-encrypted tamper-proof document sealing with three seal modes +/// @dev Implements PIP-0010 / LP-0535: Public, ZK, and Private (FHE) seals +contract DataSeal { + /// @notice Seal modes matching PIP-0010 specification + enum SealMode { + Public, // Fully transparent, anyone can verify + ZK, // Content hidden, properties provable via ZK + Private // FHE-encrypted with selective disclosure + } + + struct Seal { + bytes32 contentHash; // SHA-256 of original content + euint32 encryptedTag; // FHE-encrypted integrity tag + address creator; // Seal creator + uint256 timestamp; // Block timestamp at creation + SealMode mode; // Seal type + bool revoked; // Revocation status + } + + /// @notice All seals indexed by ID + mapping(uint256 => Seal) public seals; + uint256 public sealCount; + + /// @notice Batch seal tracking + mapping(bytes32 => uint256[]) public batchSeals; // batchId => sealIds + + /// @notice Seal verification results (encrypted) + mapping(uint256 => euint32) private verificationResults; + + event SealCreated(uint256 indexed sealId, address indexed creator, SealMode mode, bytes32 contentHash); + event SealVerified(uint256 indexed sealId, address indexed verifier); + event SealRevoked(uint256 indexed sealId, address indexed revoker); + event BatchSealed(bytes32 indexed batchId, uint256 sealCount); + + /// @notice Create a new data integrity seal + /// @param contentHash SHA-256 hash of the content being sealed + /// @param encryptedTag FHE-encrypted integrity tag (creator's secret) + /// @param mode Seal mode: Public, ZK, or Private + /// @return sealId The ID of the newly created seal + function createSeal( + bytes32 contentHash, + inEuint32 calldata encryptedTag, + SealMode mode + ) external returns (uint256 sealId) { + sealId = sealCount++; + + euint32 tag = FHE.asEuint32(encryptedTag); + FHE.allowThis(tag); + FHE.allow(tag, msg.sender); + + seals[sealId] = Seal({ + contentHash: contentHash, + encryptedTag: tag, + creator: msg.sender, + timestamp: block.timestamp, + mode: mode, + revoked: false + }); + + emit SealCreated(sealId, msg.sender, mode, contentHash); + } + + /// @notice Verify a seal by comparing encrypted tags homomorphically + /// @param sealId The seal to verify + /// @param challengeTag Encrypted tag from the verifier + /// @dev Result is FHE-encrypted: 1 = match, 0 = mismatch + function verifySeal( + uint256 sealId, + inEuint32 calldata challengeTag + ) external { + Seal storage seal = seals[sealId]; + require(!seal.revoked, "Seal revoked"); + + euint32 challenge = FHE.asEuint32(challengeTag); + + // Homomorphic equality check: encrypted comparison + // If tags match, result = 1; otherwise result = 0 + ebool isEqual = FHE.eq(seal.encryptedTag, challenge); + euint32 result = FHE.select(isEqual, FHE.asEuint32(1), FHE.asEuint32(0)); + + FHE.allowThis(result); + FHE.allow(result, msg.sender); + verificationResults[sealId] = result; + + // Request decryption for the verifier + FHE.decrypt(result); + + emit SealVerified(sealId, msg.sender); + } + + /// @notice Get the decrypted verification result + /// @param sealId The seal that was verified + /// @return matched Whether the verification passed + /// @return ready Whether the decryption is complete + function getVerificationResult(uint256 sealId) external view returns (bool matched, bool ready) { + euint32 result = verificationResults[sealId]; + (uint256 value, bool decrypted) = FHE.getDecryptResultSafe(result); + return (value == 1, decrypted); + } + + /// @notice Create multiple seals in a single transaction (batch sealing) + /// @param contentHashes Array of content hashes + /// @param encryptedTags Array of encrypted integrity tags + /// @param mode Seal mode for all seals in the batch + /// @return batchId Identifier for the batch + function batchSeal( + bytes32[] calldata contentHashes, + inEuint32[] calldata encryptedTags, + SealMode mode + ) external returns (bytes32 batchId) { + require(contentHashes.length == encryptedTags.length, "Length mismatch"); + require(contentHashes.length > 0, "Empty batch"); + + batchId = keccak256(abi.encodePacked(msg.sender, block.timestamp, sealCount)); + uint256[] storage ids = batchSeals[batchId]; + + for (uint256 i = 0; i < contentHashes.length; i++) { + uint256 sealId = sealCount++; + + euint32 tag = FHE.asEuint32(encryptedTags[i]); + FHE.allowThis(tag); + FHE.allow(tag, msg.sender); + + seals[sealId] = Seal({ + contentHash: contentHashes[i], + encryptedTag: tag, + creator: msg.sender, + timestamp: block.timestamp, + mode: mode, + revoked: false + }); + + ids.push(sealId); + emit SealCreated(sealId, msg.sender, mode, contentHashes[i]); + } + + emit BatchSealed(batchId, contentHashes.length); + } + + /// @notice Revoke a seal (only creator can revoke) + /// @param sealId The seal to revoke + function revokeSeal(uint256 sealId) external { + Seal storage seal = seals[sealId]; + require(seal.creator == msg.sender, "Not seal creator"); + require(!seal.revoked, "Already revoked"); + + seal.revoked = true; + emit SealRevoked(sealId, msg.sender); + } + + /// @notice Get seal metadata (public fields only) + function getSeal(uint256 sealId) external view returns ( + bytes32 contentHash, + address creator, + uint256 timestamp, + SealMode mode, + bool revoked + ) { + Seal storage seal = seals[sealId]; + return (seal.contentHash, seal.creator, seal.timestamp, seal.mode, seal.revoked); + } + + /// @notice Get all seal IDs in a batch + function getBatchSeals(bytes32 batchId) external view returns (uint256[] memory) { + return batchSeals[batchId]; + } +} diff --git a/examples/data-seal/hardhat.config.ts b/examples/data-seal/hardhat.config.ts new file mode 100644 index 0000000..8dfafd5 --- /dev/null +++ b/examples/data-seal/hardhat.config.ts @@ -0,0 +1,17 @@ +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; +import "@luxfhe/hardhat-plugin"; +import "./tasks"; + +const config: HardhatUserConfig = { + solidity: { + version: "0.8.25", + settings: { + evmVersion: "cancun", + optimizer: { enabled: true, runs: 200 }, + }, + }, + defaultNetwork: "hardhat", +}; + +export default config; diff --git a/examples/data-seal/package.json b/examples/data-seal/package.json new file mode 100644 index 0000000..e9bf0b0 --- /dev/null +++ b/examples/data-seal/package.json @@ -0,0 +1,21 @@ +{ + "name": "@luxfhe/example-data-seal", + "version": "1.0.0", + "description": "Verifiable Data Integrity Seal - FHE-encrypted tamper-proof document sealing (PIP-0010)", + "private": true, + "scripts": { + "compile": "hardhat compile", + "test": "hardhat test", + "deploy": "hardhat task:deploy-seal", + "task:seal": "hardhat task:seal", + "task:verify": "hardhat task:verify-seal", + "task:batch-seal": "hardhat task:batch-seal" + }, + "devDependencies": { + "@luxfhe/hardhat-plugin": "^0.5.0", + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "@openzeppelin/contracts": "^5.3.0", + "hardhat": "^2.22.0", + "typescript": "^5.0.0" + } +} diff --git a/examples/data-seal/tasks/batch-seal.ts b/examples/data-seal/tasks/batch-seal.ts new file mode 100644 index 0000000..e663d17 --- /dev/null +++ b/examples/data-seal/tasks/batch-seal.ts @@ -0,0 +1,32 @@ +import { task } from "hardhat/config"; +import { fhe, Encryptable } from "@luxfhe/sdk/node"; + +task("task:batch-seal", "Create a batch of data integrity seals") + .addParam("contract", "DataSeal contract address") + .addParam("count", "Number of seals to create") + .addOptionalParam("mode", "Seal mode: 0=Public, 1=ZK, 2=Private", "0") + .setAction(async ({ contract, count, mode }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const dataSeal = await hre.ethers.getContractAt("DataSeal", contract); + + const n = parseInt(count); + const hashes: string[] = []; + const tags: bigint[] = []; + const encTags: any[] = []; + + for (let i = 0; i < n; i++) { + const content = `batch-document-${i}-${Date.now()}`; + hashes.push(hre.ethers.keccak256(hre.ethers.toUtf8Bytes(content))); + const tag = BigInt(Math.floor(Math.random() * 1000000)); + tags.push(tag); + const [enc] = await fhe.encrypt([Encryptable.uint32(tag)]); + encTags.push(enc); + } + + console.log(`Creating batch of ${n} seals...`); + const tx = await dataSeal.connect(signer).batchSeal(hashes, encTags, parseInt(mode)); + const receipt = await tx.wait(); + + console.log(`Batch sealed: ${n} documents`); + console.log("Tags (save for verification):", tags.map(String)); + }); diff --git a/examples/data-seal/tasks/deploy-seal.ts b/examples/data-seal/tasks/deploy-seal.ts new file mode 100644 index 0000000..10ab57e --- /dev/null +++ b/examples/data-seal/tasks/deploy-seal.ts @@ -0,0 +1,16 @@ +import { task } from "hardhat/config"; + +task("task:deploy-seal", "Deploy the DataSeal contract").setAction( + async (_, hre) => { + const [deployer] = await hre.ethers.getSigners(); + console.log("Deploying DataSeal with:", deployer.address); + + const DataSeal = await hre.ethers.getContractFactory("DataSeal"); + const dataSeal = await DataSeal.deploy(); + await dataSeal.waitForDeployment(); + + const address = await dataSeal.getAddress(); + console.log("DataSeal deployed to:", address); + return address; + } +); diff --git a/examples/data-seal/tasks/index.ts b/examples/data-seal/tasks/index.ts new file mode 100644 index 0000000..72a83ff --- /dev/null +++ b/examples/data-seal/tasks/index.ts @@ -0,0 +1,4 @@ +import "./deploy-seal"; +import "./seal"; +import "./verify-seal"; +import "./batch-seal"; diff --git a/examples/data-seal/tasks/seal.ts b/examples/data-seal/tasks/seal.ts new file mode 100644 index 0000000..db267a0 --- /dev/null +++ b/examples/data-seal/tasks/seal.ts @@ -0,0 +1,23 @@ +import { task } from "hardhat/config"; +import { fhe, Encryptable } from "@luxfhe/sdk/node"; + +task("task:seal", "Create a data integrity seal") + .addParam("contract", "DataSeal contract address") + .addParam("hash", "Content hash (hex string)") + .addOptionalParam("mode", "Seal mode: 0=Public, 1=ZK, 2=Private", "0") + .setAction(async ({ contract, hash, mode }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const dataSeal = await hre.ethers.getContractAt("DataSeal", contract); + + // Generate a random integrity tag + const tag = BigInt(Math.floor(Math.random() * 1000000)); + console.log("Integrity tag (save this for verification):", tag.toString()); + + const [encTag] = await fhe.encrypt([Encryptable.uint32(tag)]); + const tx = await dataSeal.connect(signer).createSeal(hash, encTag, parseInt(mode)); + const receipt = await tx.wait(); + + const sealCount = await dataSeal.sealCount(); + console.log("Seal created. ID:", (sealCount - 1n).toString()); + console.log("Mode:", ["Public", "ZK", "Private"][parseInt(mode)]); + }); diff --git a/examples/data-seal/tasks/verify-seal.ts b/examples/data-seal/tasks/verify-seal.ts new file mode 100644 index 0000000..dd0018a --- /dev/null +++ b/examples/data-seal/tasks/verify-seal.ts @@ -0,0 +1,37 @@ +import { task } from "hardhat/config"; +import { fhe, Encryptable } from "@luxfhe/sdk/node"; + +task("task:verify-seal", "Verify a data integrity seal") + .addParam("contract", "DataSeal contract address") + .addParam("id", "Seal ID to verify") + .addParam("tag", "Integrity tag (the secret used at seal creation)") + .setAction(async ({ contract, id, tag }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const dataSeal = await hre.ethers.getContractAt("DataSeal", contract); + + const seal = await dataSeal.getSeal(parseInt(id)); + console.log("Seal:", { + contentHash: seal.contentHash, + creator: seal.creator, + timestamp: seal.timestamp.toString(), + mode: ["Public", "ZK", "Private"][Number(seal.mode)], + revoked: seal.revoked, + }); + + const [encChallenge] = await fhe.encrypt([Encryptable.uint32(BigInt(tag))]); + const tx = await dataSeal.connect(signer).verifySeal(parseInt(id), encChallenge); + await tx.wait(); + + // Poll for decryption result + let attempts = 0; + while (attempts < 10) { + const result = await dataSeal.getVerificationResult(parseInt(id)); + if (result.ready) { + console.log(result.matched ? "PASS: Seal verified." : "FAIL: Seal mismatch."); + return; + } + await new Promise((r) => setTimeout(r, 2000)); + attempts++; + } + console.log("Decryption pending. Check later."); + }); diff --git a/examples/data-seal/test/DataSeal.test.ts b/examples/data-seal/test/DataSeal.test.ts new file mode 100644 index 0000000..8d973cc --- /dev/null +++ b/examples/data-seal/test/DataSeal.test.ts @@ -0,0 +1,172 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { expect } from "chai"; +import hre from "hardhat"; +import { fhe, Encryptable, FheTypes } from "@luxfhe/sdk/node"; + +describe("DataSeal", function () { + async function deployFixture() { + const [owner, alice, bob] = await hre.ethers.getSigners(); + const DataSeal = await hre.ethers.getContractFactory("DataSeal"); + const dataSeal = await DataSeal.deploy(); + return { dataSeal, owner, alice, bob }; + } + + beforeEach(function () { + if (!hre.fhe.isPermittedEnvironment("MOCK")) this.skip(); + }); + + describe("Seal Creation", function () { + it("should create a Public seal", async function () { + const { dataSeal, alice } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("document content")); + + const [encTag] = await fhe.encrypt([Encryptable.uint32(42n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 0); // Public mode + + const seal = await dataSeal.getSeal(0); + expect(seal.contentHash).to.equal(contentHash); + expect(seal.creator).to.equal(alice.address); + expect(seal.mode).to.equal(0); + expect(seal.revoked).to.be.false; + }); + + it("should create a ZK seal", async function () { + const { dataSeal, alice } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("secret document")); + + const [encTag] = await fhe.encrypt([Encryptable.uint32(99n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 1); // ZK mode + + const seal = await dataSeal.getSeal(0); + expect(seal.mode).to.equal(1); + }); + + it("should create a Private (FHE) seal", async function () { + const { dataSeal, alice } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("encrypted evidence")); + + const [encTag] = await fhe.encrypt([Encryptable.uint32(777n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 2); // Private mode + + const seal = await dataSeal.getSeal(0); + expect(seal.mode).to.equal(2); + }); + + it("should increment seal count", async function () { + const { dataSeal, alice } = await loadFixture(deployFixture); + const hash1 = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc1")); + const hash2 = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc2")); + + const [enc1] = await fhe.encrypt([Encryptable.uint32(1n)]); + const [enc2] = await fhe.encrypt([Encryptable.uint32(2n)]); + + await dataSeal.connect(alice).createSeal(hash1, enc1, 0); + await dataSeal.connect(alice).createSeal(hash2, enc2, 0); + + expect(await dataSeal.sealCount()).to.equal(2); + }); + }); + + describe("Seal Verification", function () { + it("should verify matching seal (encrypted comparison)", async function () { + const { dataSeal, alice, bob } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("test document")); + + // Alice creates seal with tag=42 + const [encTag] = await fhe.encrypt([Encryptable.uint32(42n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 0); + + // Bob verifies with same tag=42 (should match) + const [challenge] = await fhe.encrypt([Encryptable.uint32(42n)]); + await dataSeal.connect(bob).verifySeal(0, challenge); + + // In mock mode, check result + const result = await dataSeal.getVerificationResult(0); + // Result depends on FHE mock implementation + expect(result.ready).to.be.true; + expect(result.matched).to.be.true; + }); + + it("should reject mismatched seal", async function () { + const { dataSeal, alice, bob } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("test document")); + + // Alice creates seal with tag=42 + const [encTag] = await fhe.encrypt([Encryptable.uint32(42n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 0); + + // Bob verifies with wrong tag=99 (should not match) + const [challenge] = await fhe.encrypt([Encryptable.uint32(99n)]); + await dataSeal.connect(bob).verifySeal(0, challenge); + + const result = await dataSeal.getVerificationResult(0); + expect(result.ready).to.be.true; + expect(result.matched).to.be.false; + }); + + it("should reject verification of revoked seal", async function () { + const { dataSeal, alice, bob } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("revoked doc")); + + const [encTag] = await fhe.encrypt([Encryptable.uint32(42n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 0); + await dataSeal.connect(alice).revokeSeal(0); + + const [challenge] = await fhe.encrypt([Encryptable.uint32(42n)]); + await expect(dataSeal.connect(bob).verifySeal(0, challenge)) + .to.be.revertedWith("Seal revoked"); + }); + }); + + describe("Batch Sealing", function () { + it("should create batch of seals", async function () { + const { dataSeal, alice } = await loadFixture(deployFixture); + + const hashes = [ + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc1")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc2")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc3")), + ]; + + const tags = await Promise.all([ + fhe.encrypt([Encryptable.uint32(1n)]), + fhe.encrypt([Encryptable.uint32(2n)]), + fhe.encrypt([Encryptable.uint32(3n)]), + ]); + + const tx = await dataSeal.connect(alice).batchSeal( + hashes, + tags.map((t) => t[0]), + 0 + ); + + const receipt = await tx.wait(); + expect(await dataSeal.sealCount()).to.equal(3); + }); + }); + + describe("Seal Revocation", function () { + it("should allow creator to revoke", async function () { + const { dataSeal, alice } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc")); + + const [encTag] = await fhe.encrypt([Encryptable.uint32(1n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 0); + await dataSeal.connect(alice).revokeSeal(0); + + const seal = await dataSeal.getSeal(0); + expect(seal.revoked).to.be.true; + }); + + it("should reject non-creator revocation", async function () { + const { dataSeal, alice, bob } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc")); + + const [encTag] = await fhe.encrypt([Encryptable.uint32(1n)]); + await dataSeal.connect(alice).createSeal(contentHash, encTag, 0); + + await expect(dataSeal.connect(bob).revokeSeal(0)) + .to.be.revertedWith("Not seal creator"); + }); + }); +}); diff --git a/examples/data-seal/tsconfig.json b/examples/data-seal/tsconfig.json new file mode 100644 index 0000000..574e785 --- /dev/null +++ b/examples/data-seal/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/examples/encrypted-crdt/README.md b/examples/encrypted-crdt/README.md new file mode 100644 index 0000000..3498889 --- /dev/null +++ b/examples/encrypted-crdt/README.md @@ -0,0 +1,52 @@ +# Encrypted CRDT - Offline-First Privacy + +FHE-encrypted Conflict-Free Replicated Data Types for privacy-preserving collaboration. + +**Implements**: [PIP-0013](https://pips.pars.network/PIPs/pip-0013-encrypted-crdt) / [LP-6500](https://lps.lux.network/docs/lp-6500-fhecrdt-architecture) + +## CRDT Types + +| Type | Description | Merge Strategy | +|------|-------------|----------------| +| **LWW-Register** | Last-Writer-Wins Register | Higher timestamp wins, address tiebreak | +| **OR-Set** | Observed-Remove Set | Tag-based add/remove with tombstones | + +## How It Works + +1. **Write**: Encrypt your value with FHE, assign a Lamport timestamp, store on-chain +2. **Sync**: Peers exchange encrypted registers — values never revealed during sync +3. **Merge**: Deterministic conflict resolution (LWW) operates on encrypted values +4. **Read**: Request threshold decryption only when you need the plaintext + +## Key Properties + +- **Convergence**: All replicas converge to the same state after syncing all operations +- **Privacy**: Values encrypted end-to-end — even the chain only sees ciphertext +- **Offline-first**: Lamport timestamps enable conflict resolution after offline edits +- **Mesh-compatible**: Works over sneakernet, Bluetooth, or any transport + +## Quick Start + +```bash +npm install +npx hardhat compile +npx hardhat test + +# Deploy +npx hardhat task:deploy-crdt + +# Set a register +npx hardhat task:set-register --contract --doc myDoc --field title --value 42 --timestamp 1 + +# Read a register +npx hardhat task:get-register --contract --doc myDoc --field title + +# Merge conflicting registers +npx hardhat task:merge-registers --contract --doc myDoc --field1 alice-title --field2 bob-title --result merged-title +``` + +## Related + +- **Go implementation**: [github.com/luxfi/fhe/cmd/crdt](https://github.com/luxfi/fhe/tree/main/cmd/crdt) — Boolean-circuit LWW-Register with MUX selection +- **LP-6501**: DocReceipts (on-chain document update receipts) +- **LP-6502**: DAReceipts (data availability certificates) diff --git a/examples/encrypted-crdt/contracts/EncryptedCRDT.sol b/examples/encrypted-crdt/contracts/EncryptedCRDT.sol new file mode 100644 index 0000000..cfc5bc1 --- /dev/null +++ b/examples/encrypted-crdt/contracts/EncryptedCRDT.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "@luxfhe/luxfhe-contracts/FHE.sol"; + +/// @title EncryptedCRDT - FHE-Encrypted Conflict-Free Replicated Data Types +/// @notice Last-Writer-Wins Register and OR-Set with encrypted values +/// @dev Implements PIP-0013 / LP-6500: Privacy-preserving offline-first collaboration +contract EncryptedCRDT { + /// @notice LWW-Register: encrypted value with Lamport timestamp for conflict resolution + struct LWWRegister { + euint32 value; // FHE-encrypted value + uint256 timestamp; // Lamport clock (higher wins) + address lastWriter; // Who last wrote + bytes32 documentId; // Parent document + bool exists; + } + + /// @notice OR-Set element: encrypted value with unique tag for add/remove semantics + struct ORSetElement { + euint32 value; // FHE-encrypted element value + bytes32 uniqueTag; // Unique tag for add-remove semantics + bool removed; // Tombstone flag + } + + /// @notice LWW Registers indexed by (documentId, fieldName) + mapping(bytes32 => LWWRegister) public registers; + + /// @notice OR-Set elements indexed by setId + mapping(bytes32 => ORSetElement[]) public sets; + + /// @notice Merge results for conflict resolution + mapping(bytes32 => euint32) private mergeResults; + + /// @notice Document receipts counter + uint256 public operationCount; + + event RegisterUpdated(bytes32 indexed registerId, address indexed writer, uint256 timestamp); + event SetElementAdded(bytes32 indexed setId, bytes32 uniqueTag); + event SetElementRemoved(bytes32 indexed setId, bytes32 uniqueTag); + event RegistersMerged(bytes32 indexed registerId, address indexed merger); + + /// @notice Set a LWW-Register value (higher timestamp wins) + /// @param registerId Unique register identifier (keccak256 of docId + fieldName) + /// @param encryptedValue FHE-encrypted new value + /// @param timestamp Lamport clock value + /// @param documentId Parent document identifier + function setRegister( + bytes32 registerId, + inEuint32 calldata encryptedValue, + uint256 timestamp, + bytes32 documentId + ) external { + LWWRegister storage reg = registers[registerId]; + + // LWW semantics: only accept if timestamp is higher + if (reg.exists && timestamp <= reg.timestamp) { + // Tie-breaking: higher address wins (deterministic) + if (timestamp == reg.timestamp && msg.sender <= reg.lastWriter) { + revert("Stale update: lower timestamp or address"); + } + if (timestamp < reg.timestamp) { + revert("Stale update: lower timestamp"); + } + } + + euint32 value = FHE.asEuint32(encryptedValue); + FHE.allowThis(value); + FHE.allow(value, msg.sender); + + registers[registerId] = LWWRegister({ + value: value, + timestamp: timestamp, + lastWriter: msg.sender, + documentId: documentId, + exists: true + }); + + operationCount++; + emit RegisterUpdated(registerId, msg.sender, timestamp); + } + + /// @notice Merge two registers homomorphically (take the one with higher timestamp) + /// @dev Uses FHE.select to pick winner without revealing values + /// @param registerId1 First register + /// @param registerId2 Second register (from peer's sync) + /// @param resultId Where to store the merged result + function mergeRegisters( + bytes32 registerId1, + bytes32 registerId2, + bytes32 resultId + ) external { + LWWRegister storage reg1 = registers[registerId1]; + LWWRegister storage reg2 = registers[registerId2]; + require(reg1.exists && reg2.exists, "Registers must exist"); + + // Deterministic merge: higher timestamp wins + euint32 merged; + uint256 winnerTs; + address winnerAddr; + + if (reg1.timestamp > reg2.timestamp) { + merged = reg1.value; + winnerTs = reg1.timestamp; + winnerAddr = reg1.lastWriter; + } else if (reg2.timestamp > reg1.timestamp) { + merged = reg2.value; + winnerTs = reg2.timestamp; + winnerAddr = reg2.lastWriter; + } else { + // Same timestamp: use FHE.select based on address comparison + if (reg1.lastWriter > reg2.lastWriter) { + merged = reg1.value; + winnerAddr = reg1.lastWriter; + } else { + merged = reg2.value; + winnerAddr = reg2.lastWriter; + } + winnerTs = reg1.timestamp; + } + + FHE.allowThis(merged); + FHE.allow(merged, msg.sender); + + registers[resultId] = LWWRegister({ + value: merged, + timestamp: winnerTs, + lastWriter: winnerAddr, + documentId: reg1.documentId, + exists: true + }); + + mergeResults[resultId] = merged; + operationCount++; + emit RegistersMerged(resultId, msg.sender); + } + + /// @notice Add an element to an OR-Set + /// @param setId Set identifier + /// @param encryptedValue FHE-encrypted element value + /// @return tag Unique tag for this element (needed for removal) + function addToSet( + bytes32 setId, + inEuint32 calldata encryptedValue + ) external returns (bytes32 tag) { + euint32 value = FHE.asEuint32(encryptedValue); + FHE.allowThis(value); + FHE.allow(value, msg.sender); + + tag = keccak256(abi.encodePacked(setId, msg.sender, block.timestamp, sets[setId].length)); + + sets[setId].push(ORSetElement({ + value: value, + uniqueTag: tag, + removed: false + })); + + operationCount++; + emit SetElementAdded(setId, tag); + } + + /// @notice Remove an element from an OR-Set by its unique tag + /// @param setId Set identifier + /// @param tag The unique tag of the element to remove + function removeFromSet(bytes32 setId, bytes32 tag) external { + ORSetElement[] storage elements = sets[setId]; + for (uint256 i = 0; i < elements.length; i++) { + if (elements[i].uniqueTag == tag && !elements[i].removed) { + elements[i].removed = true; + operationCount++; + emit SetElementRemoved(setId, tag); + return; + } + } + revert("Element not found or already removed"); + } + + /// @notice Get the current register value (request decryption) + /// @param registerId The register to read + function decryptRegister(bytes32 registerId) external { + LWWRegister storage reg = registers[registerId]; + require(reg.exists, "Register not found"); + FHE.allow(reg.value, msg.sender); + FHE.decrypt(reg.value); + } + + /// @notice Get decrypted register value + function getDecryptedRegister(bytes32 registerId) external view returns (uint256 value, bool ready) { + LWWRegister storage reg = registers[registerId]; + require(reg.exists, "Register not found"); + (uint256 v, bool decrypted) = FHE.getDecryptResultSafe(reg.value); + return (v, decrypted); + } + + /// @notice Get register metadata + function getRegisterInfo(bytes32 registerId) external view returns ( + uint256 timestamp, + address lastWriter, + bytes32 documentId, + bool exists + ) { + LWWRegister storage reg = registers[registerId]; + return (reg.timestamp, reg.lastWriter, reg.documentId, reg.exists); + } + + /// @notice Get active (non-removed) element count in an OR-Set + function getSetSize(bytes32 setId) external view returns (uint256 active) { + ORSetElement[] storage elements = sets[setId]; + for (uint256 i = 0; i < elements.length; i++) { + if (!elements[i].removed) active++; + } + } +} diff --git a/examples/encrypted-crdt/hardhat.config.ts b/examples/encrypted-crdt/hardhat.config.ts new file mode 100644 index 0000000..8dfafd5 --- /dev/null +++ b/examples/encrypted-crdt/hardhat.config.ts @@ -0,0 +1,17 @@ +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; +import "@luxfhe/hardhat-plugin"; +import "./tasks"; + +const config: HardhatUserConfig = { + solidity: { + version: "0.8.25", + settings: { + evmVersion: "cancun", + optimizer: { enabled: true, runs: 200 }, + }, + }, + defaultNetwork: "hardhat", +}; + +export default config; diff --git a/examples/encrypted-crdt/package.json b/examples/encrypted-crdt/package.json new file mode 100644 index 0000000..01f594e --- /dev/null +++ b/examples/encrypted-crdt/package.json @@ -0,0 +1,21 @@ +{ + "name": "@luxfhe/example-encrypted-crdt", + "version": "1.0.0", + "description": "Encrypted CRDT for offline-first privacy-preserving collaboration (PIP-0013)", + "private": true, + "scripts": { + "compile": "hardhat compile", + "test": "hardhat test", + "deploy": "hardhat task:deploy-crdt", + "task:set": "hardhat task:set-register", + "task:get": "hardhat task:get-register", + "task:merge": "hardhat task:merge-registers" + }, + "devDependencies": { + "@luxfhe/hardhat-plugin": "^0.5.0", + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "@openzeppelin/contracts": "^5.3.0", + "hardhat": "^2.22.0", + "typescript": "^5.0.0" + } +} diff --git a/examples/encrypted-crdt/tasks/deploy-crdt.ts b/examples/encrypted-crdt/tasks/deploy-crdt.ts new file mode 100644 index 0000000..9870fc0 --- /dev/null +++ b/examples/encrypted-crdt/tasks/deploy-crdt.ts @@ -0,0 +1,16 @@ +import { task } from "hardhat/config"; + +task("task:deploy-crdt", "Deploy the EncryptedCRDT contract").setAction( + async (_, hre) => { + const [deployer] = await hre.ethers.getSigners(); + console.log("Deploying EncryptedCRDT with:", deployer.address); + + const Factory = await hre.ethers.getContractFactory("EncryptedCRDT"); + const crdt = await Factory.deploy(); + await crdt.waitForDeployment(); + + const address = await crdt.getAddress(); + console.log("EncryptedCRDT deployed to:", address); + return address; + } +); diff --git a/examples/encrypted-crdt/tasks/get-register.ts b/examples/encrypted-crdt/tasks/get-register.ts new file mode 100644 index 0000000..505cc55 --- /dev/null +++ b/examples/encrypted-crdt/tasks/get-register.ts @@ -0,0 +1,42 @@ +import { task } from "hardhat/config"; + +task("task:get-register", "Get register info and request decryption") + .addParam("contract", "EncryptedCRDT contract address") + .addParam("doc", "Document ID") + .addParam("field", "Field name") + .setAction(async ({ contract, doc, field }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const crdt = await hre.ethers.getContractAt("EncryptedCRDT", contract); + + const regId = hre.ethers.keccak256( + hre.ethers.solidityPacked(["string", "string"], [doc, field]) + ); + + const info = await crdt.getRegisterInfo(regId); + if (!info.exists) { + console.log("Register not found."); + return; + } + + console.log("Register:", { + timestamp: info.timestamp.toString(), + lastWriter: info.lastWriter, + documentId: info.documentId, + }); + + // Request decryption + await crdt.connect(signer).decryptRegister(regId); + + // Poll for result + let attempts = 0; + while (attempts < 10) { + const result = await crdt.getDecryptedRegister(regId); + if (result.ready) { + console.log("Decrypted value:", result.value.toString()); + return; + } + await new Promise((r) => setTimeout(r, 2000)); + attempts++; + } + console.log("Decryption pending."); + }); diff --git a/examples/encrypted-crdt/tasks/index.ts b/examples/encrypted-crdt/tasks/index.ts new file mode 100644 index 0000000..acf0411 --- /dev/null +++ b/examples/encrypted-crdt/tasks/index.ts @@ -0,0 +1,4 @@ +import "./deploy-crdt"; +import "./set-register"; +import "./get-register"; +import "./merge-registers"; diff --git a/examples/encrypted-crdt/tasks/merge-registers.ts b/examples/encrypted-crdt/tasks/merge-registers.ts new file mode 100644 index 0000000..d57e0f5 --- /dev/null +++ b/examples/encrypted-crdt/tasks/merge-registers.ts @@ -0,0 +1,31 @@ +import { task } from "hardhat/config"; + +task("task:merge-registers", "Merge two registers (LWW conflict resolution)") + .addParam("contract", "EncryptedCRDT contract address") + .addParam("doc", "Document ID") + .addParam("field1", "First field name") + .addParam("field2", "Second field name") + .addParam("result", "Result field name") + .setAction(async ({ contract, doc, field1, field2, result }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const crdt = await hre.ethers.getContractAt("EncryptedCRDT", contract); + + const regId1 = hre.ethers.keccak256( + hre.ethers.solidityPacked(["string", "string"], [doc, field1]) + ); + const regId2 = hre.ethers.keccak256( + hre.ethers.solidityPacked(["string", "string"], [doc, field2]) + ); + const resultId = hre.ethers.keccak256( + hre.ethers.solidityPacked(["string", "string"], [doc, result]) + ); + + const tx = await crdt.connect(signer).mergeRegisters(regId1, regId2, resultId); + await tx.wait(); + + const info = await crdt.getRegisterInfo(resultId); + console.log("Merged register:", { + timestamp: info.timestamp.toString(), + lastWriter: info.lastWriter, + }); + }); diff --git a/examples/encrypted-crdt/tasks/set-register.ts b/examples/encrypted-crdt/tasks/set-register.ts new file mode 100644 index 0000000..f8daef9 --- /dev/null +++ b/examples/encrypted-crdt/tasks/set-register.ts @@ -0,0 +1,24 @@ +import { task } from "hardhat/config"; +import { fhe, Encryptable } from "@luxfhe/sdk/node"; + +task("task:set-register", "Set an encrypted LWW-Register value") + .addParam("contract", "EncryptedCRDT contract address") + .addParam("doc", "Document ID") + .addParam("field", "Field name") + .addParam("value", "Value to set (integer)") + .addParam("timestamp", "Lamport timestamp") + .setAction(async ({ contract, doc, field, value, timestamp }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const crdt = await hre.ethers.getContractAt("EncryptedCRDT", contract); + + const regId = hre.ethers.keccak256( + hre.ethers.solidityPacked(["string", "string"], [doc, field]) + ); + const docId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes(doc)); + + const [encVal] = await fhe.encrypt([Encryptable.uint32(BigInt(value))]); + const tx = await crdt.connect(signer).setRegister(regId, encVal, parseInt(timestamp), docId); + await tx.wait(); + + console.log(`Register set: ${doc}.${field} = [encrypted] @ t=${timestamp}`); + }); diff --git a/examples/encrypted-crdt/test/EncryptedCRDT.test.ts b/examples/encrypted-crdt/test/EncryptedCRDT.test.ts new file mode 100644 index 0000000..aa69de2 --- /dev/null +++ b/examples/encrypted-crdt/test/EncryptedCRDT.test.ts @@ -0,0 +1,160 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { expect } from "chai"; +import hre from "hardhat"; +import { fhe, Encryptable, FheTypes } from "@luxfhe/sdk/node"; + +describe("EncryptedCRDT", function () { + async function deployFixture() { + const [owner, alice, bob, charlie] = await hre.ethers.getSigners(); + const Factory = await hre.ethers.getContractFactory("EncryptedCRDT"); + const crdt = await Factory.deploy(); + return { crdt, owner, alice, bob, charlie }; + } + + beforeEach(function () { + if (!hre.fhe.isPermittedEnvironment("MOCK")) this.skip(); + }); + + function registerId(docId: string, field: string): string { + return hre.ethers.keccak256( + hre.ethers.solidityPacked(["string", "string"], [docId, field]) + ); + } + + describe("LWW-Register", function () { + it("should set and read an encrypted register", async function () { + const { crdt, alice } = await loadFixture(deployFixture); + const regId = registerId("doc1", "title"); + const docId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc1")); + + const [encVal] = await fhe.encrypt([Encryptable.uint32(42n)]); + await crdt.connect(alice).setRegister(regId, encVal, 1, docId); + + const info = await crdt.getRegisterInfo(regId); + expect(info.exists).to.be.true; + expect(info.timestamp).to.equal(1); + expect(info.lastWriter).to.equal(alice.address); + }); + + it("should accept higher timestamp (LWW semantics)", async function () { + const { crdt, alice, bob } = await loadFixture(deployFixture); + const regId = registerId("doc1", "title"); + const docId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc1")); + + // Alice writes at t=1 + const [enc1] = await fhe.encrypt([Encryptable.uint32(10n)]); + await crdt.connect(alice).setRegister(regId, enc1, 1, docId); + + // Bob writes at t=2 (wins) + const [enc2] = await fhe.encrypt([Encryptable.uint32(20n)]); + await crdt.connect(bob).setRegister(regId, enc2, 2, docId); + + const info = await crdt.getRegisterInfo(regId); + expect(info.timestamp).to.equal(2); + expect(info.lastWriter).to.equal(bob.address); + }); + + it("should reject stale updates (lower timestamp)", async function () { + const { crdt, alice, bob } = await loadFixture(deployFixture); + const regId = registerId("doc1", "title"); + const docId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc1")); + + // Alice writes at t=5 + const [enc1] = await fhe.encrypt([Encryptable.uint32(10n)]); + await crdt.connect(alice).setRegister(regId, enc1, 5, docId); + + // Bob tries to write at t=3 (rejected) + const [enc2] = await fhe.encrypt([Encryptable.uint32(20n)]); + await expect(crdt.connect(bob).setRegister(regId, enc2, 3, docId)) + .to.be.revertedWith("Stale update: lower timestamp"); + }); + + it("should decrypt register value", async function () { + const { crdt, alice } = await loadFixture(deployFixture); + const regId = registerId("doc1", "title"); + const docId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc1")); + + const [encVal] = await fhe.encrypt([Encryptable.uint32(99n)]); + await crdt.connect(alice).setRegister(regId, encVal, 1, docId); + await crdt.connect(alice).decryptRegister(regId); + + const result = await crdt.getDecryptedRegister(regId); + expect(result.ready).to.be.true; + expect(result.value).to.equal(99); + }); + }); + + describe("Register Merge", function () { + it("should merge two registers (higher timestamp wins)", async function () { + const { crdt, alice, bob } = await loadFixture(deployFixture); + const docId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("doc1")); + + const regA = registerId("doc1", "title-alice"); + const regB = registerId("doc1", "title-bob"); + const regMerged = registerId("doc1", "title-merged"); + + // Alice's version at t=3 + const [enc1] = await fhe.encrypt([Encryptable.uint32(100n)]); + await crdt.connect(alice).setRegister(regA, enc1, 3, docId); + + // Bob's version at t=5 (wins) + const [enc2] = await fhe.encrypt([Encryptable.uint32(200n)]); + await crdt.connect(bob).setRegister(regB, enc2, 5, docId); + + // Merge + await crdt.connect(alice).mergeRegisters(regA, regB, regMerged); + + const info = await crdt.getRegisterInfo(regMerged); + expect(info.timestamp).to.equal(5); + expect(info.lastWriter).to.equal(bob.address); + }); + }); + + describe("OR-Set", function () { + it("should add elements to a set", async function () { + const { crdt, alice } = await loadFixture(deployFixture); + const setId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("members")); + + const [enc1] = await fhe.encrypt([Encryptable.uint32(1n)]); + await crdt.connect(alice).addToSet(setId, enc1); + + const [enc2] = await fhe.encrypt([Encryptable.uint32(2n)]); + await crdt.connect(alice).addToSet(setId, enc2); + + const size = await crdt.getSetSize(setId); + expect(size).to.equal(2); + }); + + it("should remove elements by tag", async function () { + const { crdt, alice } = await loadFixture(deployFixture); + const setId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("members")); + + const [enc1] = await fhe.encrypt([Encryptable.uint32(1n)]); + const tx = await crdt.connect(alice).addToSet(setId, enc1); + const receipt = await tx.wait(); + + // Get the tag from the event + const event = receipt?.logs.find((l: any) => l.fragment?.name === "SetElementAdded"); + const tag = (event as any)?.args?.uniqueTag; + + if (tag) { + await crdt.connect(alice).removeFromSet(setId, tag); + const size = await crdt.getSetSize(setId); + expect(size).to.equal(0); + } + }); + + it("should track operation count", async function () { + const { crdt, alice } = await loadFixture(deployFixture); + const setId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("ops")); + + const [enc1] = await fhe.encrypt([Encryptable.uint32(1n)]); + await crdt.connect(alice).addToSet(setId, enc1); + + const [enc2] = await fhe.encrypt([Encryptable.uint32(2n)]); + await crdt.connect(alice).addToSet(setId, enc2); + + expect(await crdt.operationCount()).to.equal(2); + }); + }); +}); diff --git a/examples/encrypted-crdt/tsconfig.json b/examples/encrypted-crdt/tsconfig.json new file mode 100644 index 0000000..574e785 --- /dev/null +++ b/examples/encrypted-crdt/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/examples/shadow-governance/README.md b/examples/shadow-governance/README.md new file mode 100644 index 0000000..a031122 --- /dev/null +++ b/examples/shadow-governance/README.md @@ -0,0 +1,60 @@ +# Shadow Governance - Anonymous Parallel Government + +FHE-encrypted anonymous governance protocol for shadow government operations. + +**Implements**: [PIP-7010](https://pips.pars.network/PIPs/pip-7010-shadow-governance) / Shadow Government Protocol for [pars.vote](https://pars.vote) + +## Features + +- **Shadow Ministries**: Parallel government departments monitoring real governance +- **Anonymous Proposals**: Submit proposals without revealing identity +- **Encrypted Voting**: FHE-encrypted ballots — homomorphic tallying without decrypting individual votes +- **Nullifier-based Anti-Fraud**: Prevent double-voting without linking votes to identities +- **Quorum Enforcement**: Configurable quorum requirements + +## How It Works + +1. **Admin** creates shadow ministries (Education, Health, Economy, etc.) +2. **Anyone** submits proposals tied to a ministry +3. **Participants** vote with encrypted ballots using unique nullifiers +4. After the voting period, **tally** decrypts aggregate counts (not individual votes) +5. **Finalization** checks quorum and majority to pass/reject + +## Anonymity Guarantees + +- **Vote privacy**: Individual votes are FHE-encrypted; only the aggregate is decrypted +- **Voter unlinkability**: Nullifier = hash(secret || proposalId) — cannot link voter to vote +- **Proposal anonymity**: Proposals can be submitted via relay/mixer for full anonymity + +## Quick Start + +```bash +npm install +npx hardhat compile +npx hardhat test + +# Deploy +npx hardhat task:deploy-gov + +# Create a ministry +# (done programmatically in deploy or via admin functions) + +# Create a proposal +npx hardhat task:propose --contract --content "Reform education" --ministry + +# Vote anonymously +npx hardhat task:vote --contract --proposal 0 --choice yes --secret "my-secret" + +# Tally after voting period +npx hardhat task:tally --contract --proposal 0 + +# List ministries +npx hardhat task:list-ministries --contract +``` + +## Related + +- **Go implementation**: [github.com/luxfi/fhe/cmd/vote](https://github.com/luxfi/fhe/tree/main/cmd/vote) — Boolean-circuit encrypted voting +- **Pars voting app**: [pars.vote](https://pars.vote) — Production deployment +- **PIP-0012**: Encrypted Voting protocol +- **LP-6500**: fheCRDT Architecture diff --git a/examples/shadow-governance/contracts/ShadowGovernance.sol b/examples/shadow-governance/contracts/ShadowGovernance.sol new file mode 100644 index 0000000..289a439 --- /dev/null +++ b/examples/shadow-governance/contracts/ShadowGovernance.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "@luxfhe/luxfhe-contracts/FHE.sol"; + +/// @title ShadowGovernance - Anonymous Parallel Governance Protocol +/// @notice Encrypted voting, anonymous proposals, and shadow ministry management +/// @dev Implements PIP-7010: Shadow Government Protocol for pars.vote +contract ShadowGovernance { + /// @notice Proposal status + enum ProposalStatus { + Active, // Accepting votes + Tallying, // Vote period ended, tallying in progress + Passed, // Proposal passed + Rejected, // Proposal rejected + Cancelled // Cancelled by proposer + } + + /// @notice Ministry (shadow government department) + struct Ministry { + bytes32 name; // Ministry name hash + bytes32 descriptionHash; // Description hash + uint256 memberCount; // Number of members + bool active; + } + + /// @notice Governance proposal + struct Proposal { + bytes32 contentHash; // Hash of proposal content (stored off-chain) + bytes32 ministryId; // Which ministry this belongs to + uint256 voteDeadline; // Block number when voting ends + euint32 yesVotes; // FHE-encrypted yes vote count + euint32 noVotes; // FHE-encrypted no vote count + uint256 voterCount; // Number of voters (public for quorum) + ProposalStatus status; + address proposer; // Anonymous via relay + } + + /// @notice All proposals + mapping(uint256 => Proposal) public proposals; + uint256 public proposalCount; + + /// @notice Ministries + mapping(bytes32 => Ministry) public ministries; + bytes32[] public ministryIds; + + /// @notice Double-vote prevention via nullifiers (hashed commitments) + mapping(uint256 => mapping(bytes32 => bool)) public nullifiers; + + /// @notice Tally results + mapping(uint256 => euint32) private tallyResults; + + /// @notice Quorum: minimum voters needed (as percentage * 100) + uint256 public quorumBps = 1000; // 10% + + /// @notice Total registered participants + uint256 public totalParticipants; + + /// @notice Governance admin (multisig in production) + address public admin; + + event MinistryCreated(bytes32 indexed ministryId, bytes32 name); + event ProposalCreated(uint256 indexed proposalId, bytes32 indexed ministryId, bytes32 contentHash); + event VoteCast(uint256 indexed proposalId, bytes32 nullifier); + event TallyStarted(uint256 indexed proposalId); + event ProposalFinalized(uint256 indexed proposalId, ProposalStatus status); + + modifier onlyAdmin() { + require(msg.sender == admin, "Not admin"); + _; + } + + constructor() { + admin = msg.sender; + } + + /// @notice Create a shadow ministry + /// @param ministryId Unique identifier + /// @param name Ministry name hash + /// @param descriptionHash Description hash + function createMinistry( + bytes32 ministryId, + bytes32 name, + bytes32 descriptionHash + ) external onlyAdmin { + require(!ministries[ministryId].active, "Ministry exists"); + + ministries[ministryId] = Ministry({ + name: name, + descriptionHash: descriptionHash, + memberCount: 0, + active: true + }); + + ministryIds.push(ministryId); + emit MinistryCreated(ministryId, name); + } + + /// @notice Register participants (admin sets total for quorum calculation) + function setParticipantCount(uint256 count) external onlyAdmin { + totalParticipants = count; + } + + /// @notice Create a governance proposal + /// @param contentHash Hash of the proposal content + /// @param ministryId Ministry this proposal belongs to + /// @param votingBlocks Number of blocks for the voting period + /// @return proposalId The new proposal ID + function createProposal( + bytes32 contentHash, + bytes32 ministryId, + uint256 votingBlocks + ) external returns (uint256 proposalId) { + require(ministries[ministryId].active, "Ministry not active"); + require(votingBlocks > 0, "Invalid voting period"); + + proposalId = proposalCount++; + + // Initialize encrypted vote counters to zero + euint32 zeroVotes = FHE.asEuint32(0); + FHE.allowThis(zeroVotes); + + euint32 zeroNo = FHE.asEuint32(0); + FHE.allowThis(zeroNo); + + proposals[proposalId] = Proposal({ + contentHash: contentHash, + ministryId: ministryId, + voteDeadline: block.number + votingBlocks, + yesVotes: zeroVotes, + noVotes: zeroNo, + voterCount: 0, + status: ProposalStatus.Active, + proposer: msg.sender + }); + + emit ProposalCreated(proposalId, ministryId, contentHash); + } + + /// @notice Cast an anonymous encrypted vote + /// @param proposalId Proposal to vote on + /// @param encryptedVote FHE-encrypted vote (1 = yes, 0 = no) + /// @param nullifier Unique nullifier to prevent double-voting + /// @dev Nullifier = hash(secret || proposalId) — verifier cannot link to identity + function castVote( + uint256 proposalId, + inEuint32 calldata encryptedVote, + bytes32 nullifier + ) external { + Proposal storage p = proposals[proposalId]; + require(p.status == ProposalStatus.Active, "Not active"); + require(block.number <= p.voteDeadline, "Voting ended"); + require(!nullifiers[proposalId][nullifier], "Already voted"); + + // Mark nullifier as used + nullifiers[proposalId][nullifier] = true; + + euint32 vote = FHE.asEuint32(encryptedVote); + + // Homomorphic tally: add vote to yes counter + // vote=1 means yes, vote=0 means no + // yesVotes += vote + p.yesVotes = FHE.add(p.yesVotes, vote); + FHE.allowThis(p.yesVotes); + + // noVotes += (1 - vote) via select + euint32 one = FHE.asEuint32(1); + euint32 noIncrement = FHE.sub(one, vote); + p.noVotes = FHE.add(p.noVotes, noIncrement); + FHE.allowThis(p.noVotes); + + p.voterCount++; + + emit VoteCast(proposalId, nullifier); + } + + /// @notice Start the tally process (request decryption) + /// @param proposalId Proposal to tally + function startTally(uint256 proposalId) external { + Proposal storage p = proposals[proposalId]; + require(p.status == ProposalStatus.Active, "Not active"); + require(block.number > p.voteDeadline, "Voting still open"); + + p.status = ProposalStatus.Tallying; + + // Request decryption of vote counts + FHE.decrypt(p.yesVotes); + FHE.decrypt(p.noVotes); + + emit TallyStarted(proposalId); + } + + /// @notice Finalize the proposal after decryption + /// @param proposalId Proposal to finalize + function finalize(uint256 proposalId) external { + Proposal storage p = proposals[proposalId]; + require(p.status == ProposalStatus.Tallying, "Not tallying"); + + (uint256 yesCount, bool yesReady) = FHE.getDecryptResultSafe(p.yesVotes); + (uint256 noCount, bool noReady) = FHE.getDecryptResultSafe(p.noVotes); + require(yesReady && noReady, "Decryption pending"); + + // Check quorum + uint256 quorumNeeded = (totalParticipants * quorumBps) / 10000; + bool hasQuorum = p.voterCount >= quorumNeeded; + + // Simple majority with quorum + if (hasQuorum && yesCount > noCount) { + p.status = ProposalStatus.Passed; + } else { + p.status = ProposalStatus.Rejected; + } + + emit ProposalFinalized(proposalId, p.status); + } + + /// @notice Get proposal summary + function getProposal(uint256 proposalId) external view returns ( + bytes32 contentHash, + bytes32 ministryId, + uint256 voteDeadline, + uint256 voterCount, + ProposalStatus status + ) { + Proposal storage p = proposals[proposalId]; + return (p.contentHash, p.ministryId, p.voteDeadline, p.voterCount, p.status); + } + + /// @notice Get decrypted tally (only after finalization) + function getTally(uint256 proposalId) external view returns ( + uint256 yesVotes, + uint256 noVotes, + bool ready + ) { + Proposal storage p = proposals[proposalId]; + (uint256 y, bool yr) = FHE.getDecryptResultSafe(p.yesVotes); + (uint256 n, bool nr) = FHE.getDecryptResultSafe(p.noVotes); + return (y, n, yr && nr); + } + + /// @notice Get number of ministries + function getMinistryCount() external view returns (uint256) { + return ministryIds.length; + } + + /// @notice Update quorum (admin only) + function setQuorum(uint256 newQuorumBps) external onlyAdmin { + require(newQuorumBps <= 10000, "Max 100%"); + quorumBps = newQuorumBps; + } +} diff --git a/examples/shadow-governance/hardhat.config.ts b/examples/shadow-governance/hardhat.config.ts new file mode 100644 index 0000000..8dfafd5 --- /dev/null +++ b/examples/shadow-governance/hardhat.config.ts @@ -0,0 +1,17 @@ +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; +import "@luxfhe/hardhat-plugin"; +import "./tasks"; + +const config: HardhatUserConfig = { + solidity: { + version: "0.8.25", + settings: { + evmVersion: "cancun", + optimizer: { enabled: true, runs: 200 }, + }, + }, + defaultNetwork: "hardhat", +}; + +export default config; diff --git a/examples/shadow-governance/package.json b/examples/shadow-governance/package.json new file mode 100644 index 0000000..3b750fc --- /dev/null +++ b/examples/shadow-governance/package.json @@ -0,0 +1,22 @@ +{ + "name": "@luxfhe/example-shadow-governance", + "version": "1.0.0", + "description": "Shadow Government Protocol - anonymous parallel governance with FHE (PIP-7010)", + "private": true, + "scripts": { + "compile": "hardhat compile", + "test": "hardhat test", + "deploy": "hardhat task:deploy-gov", + "task:propose": "hardhat task:propose", + "task:vote": "hardhat task:vote", + "task:tally": "hardhat task:tally", + "task:ministries": "hardhat task:list-ministries" + }, + "devDependencies": { + "@luxfhe/hardhat-plugin": "^0.5.0", + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "@openzeppelin/contracts": "^5.3.0", + "hardhat": "^2.22.0", + "typescript": "^5.0.0" + } +} diff --git a/examples/shadow-governance/tasks/deploy-gov.ts b/examples/shadow-governance/tasks/deploy-gov.ts new file mode 100644 index 0000000..7e18b84 --- /dev/null +++ b/examples/shadow-governance/tasks/deploy-gov.ts @@ -0,0 +1,16 @@ +import { task } from "hardhat/config"; + +task("task:deploy-gov", "Deploy the ShadowGovernance contract").setAction( + async (_, hre) => { + const [deployer] = await hre.ethers.getSigners(); + console.log("Deploying ShadowGovernance with:", deployer.address); + + const Factory = await hre.ethers.getContractFactory("ShadowGovernance"); + const gov = await Factory.deploy(); + await gov.waitForDeployment(); + + const address = await gov.getAddress(); + console.log("ShadowGovernance deployed to:", address); + return address; + } +); diff --git a/examples/shadow-governance/tasks/index.ts b/examples/shadow-governance/tasks/index.ts new file mode 100644 index 0000000..ae937b0 --- /dev/null +++ b/examples/shadow-governance/tasks/index.ts @@ -0,0 +1,5 @@ +import "./deploy-gov"; +import "./propose"; +import "./vote"; +import "./tally"; +import "./list-ministries"; diff --git a/examples/shadow-governance/tasks/list-ministries.ts b/examples/shadow-governance/tasks/list-ministries.ts new file mode 100644 index 0000000..fb94b07 --- /dev/null +++ b/examples/shadow-governance/tasks/list-ministries.ts @@ -0,0 +1,19 @@ +import { task } from "hardhat/config"; + +task("task:list-ministries", "List all shadow ministries") + .addParam("contract", "ShadowGovernance contract address") + .setAction(async ({ contract }, hre) => { + const gov = await hre.ethers.getContractAt("ShadowGovernance", contract); + + const count = await gov.getMinistryCount(); + console.log(`Shadow Ministries: ${count.toString()}`); + + for (let i = 0; i < Number(count); i++) { + const id = await gov.ministryIds(i); + const ministry = await gov.ministries(id); + console.log(` [${i}] ${id}`); + console.log(` Name hash: ${ministry.name}`); + console.log(` Active: ${ministry.active}`); + console.log(` Members: ${ministry.memberCount.toString()}`); + } + }); diff --git a/examples/shadow-governance/tasks/propose.ts b/examples/shadow-governance/tasks/propose.ts new file mode 100644 index 0000000..6fdb0ec --- /dev/null +++ b/examples/shadow-governance/tasks/propose.ts @@ -0,0 +1,20 @@ +import { task } from "hardhat/config"; + +task("task:propose", "Create a governance proposal") + .addParam("contract", "ShadowGovernance contract address") + .addParam("content", "Proposal content text") + .addParam("ministry", "Ministry ID (hex)") + .addOptionalParam("blocks", "Voting period in blocks", "100") + .setAction(async ({ contract, content, ministry, blocks }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const gov = await hre.ethers.getContractAt("ShadowGovernance", contract); + + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes(content)); + const tx = await gov.connect(signer).createProposal(contentHash, ministry, parseInt(blocks)); + await tx.wait(); + + const count = await gov.proposalCount(); + console.log(`Proposal created. ID: ${(count - 1n).toString()}`); + console.log(`Content hash: ${contentHash}`); + console.log(`Voting period: ${blocks} blocks`); + }); diff --git a/examples/shadow-governance/tasks/tally.ts b/examples/shadow-governance/tasks/tally.ts new file mode 100644 index 0000000..7f27422 --- /dev/null +++ b/examples/shadow-governance/tasks/tally.ts @@ -0,0 +1,34 @@ +import { task } from "hardhat/config"; + +task("task:tally", "Start tally and finalize a proposal") + .addParam("contract", "ShadowGovernance contract address") + .addParam("proposal", "Proposal ID") + .setAction(async ({ contract, proposal }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const gov = await hre.ethers.getContractAt("ShadowGovernance", contract); + + const p = await gov.getProposal(parseInt(proposal)); + const statusNames = ["Active", "Tallying", "Passed", "Rejected", "Cancelled"]; + + if (Number(p.status) === 0) { + console.log("Starting tally..."); + const tx = await gov.startTally(parseInt(proposal)); + await tx.wait(); + } + + // Try to finalize + try { + const tx = await gov.finalize(parseInt(proposal)); + await tx.wait(); + + const tally = await gov.getTally(parseInt(proposal)); + const result = await gov.getProposal(parseInt(proposal)); + + console.log(`\nResult: ${statusNames[Number(result.status)]}`); + console.log(` Yes: ${tally.yesVotes.toString()}`); + console.log(` No: ${tally.noVotes.toString()}`); + console.log(` Voters: ${result.voterCount.toString()}`); + } catch (e: any) { + console.log("Decryption still pending. Try again later."); + } + }); diff --git a/examples/shadow-governance/tasks/vote.ts b/examples/shadow-governance/tasks/vote.ts new file mode 100644 index 0000000..4fa9176 --- /dev/null +++ b/examples/shadow-governance/tasks/vote.ts @@ -0,0 +1,25 @@ +import { task } from "hardhat/config"; +import { fhe, Encryptable } from "@luxfhe/sdk/node"; + +task("task:vote", "Cast an anonymous encrypted vote") + .addParam("contract", "ShadowGovernance contract address") + .addParam("proposal", "Proposal ID") + .addParam("choice", "Vote: yes or no") + .addParam("secret", "Your secret for nullifier generation") + .setAction(async ({ contract, proposal, choice, secret }, hre) => { + const [signer] = await hre.ethers.getSigners(); + const gov = await hre.ethers.getContractAt("ShadowGovernance", contract); + + const voteValue = choice.toLowerCase() === "yes" ? 1n : 0n; + const nullifier = hre.ethers.keccak256( + hre.ethers.solidityPacked(["string", "uint256"], [secret, parseInt(proposal)]) + ); + + const [encVote] = await fhe.encrypt([Encryptable.uint32(voteValue)]); + const tx = await gov.connect(signer).castVote(parseInt(proposal), encVote, nullifier); + await tx.wait(); + + console.log(`Vote cast: ${choice.toUpperCase()} on proposal ${proposal}`); + console.log("Nullifier:", nullifier); + console.log("(Your vote is encrypted — no one can see how you voted)"); + }); diff --git a/examples/shadow-governance/test/ShadowGovernance.test.ts b/examples/shadow-governance/test/ShadowGovernance.test.ts new file mode 100644 index 0000000..8251b01 --- /dev/null +++ b/examples/shadow-governance/test/ShadowGovernance.test.ts @@ -0,0 +1,170 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { expect } from "chai"; +import hre from "hardhat"; +import { fhe, Encryptable, FheTypes } from "@luxfhe/sdk/node"; + +describe("ShadowGovernance", function () { + async function deployFixture() { + const [admin, alice, bob, charlie, dave] = await hre.ethers.getSigners(); + const Factory = await hre.ethers.getContractFactory("ShadowGovernance"); + const gov = await Factory.connect(admin).deploy(); + + // Setup: create a ministry and set participants + const ministryId = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("shadow-education")); + const ministryName = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Education")); + const ministryDesc = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Shadow Ministry of Education")); + + await gov.connect(admin).createMinistry(ministryId, ministryName, ministryDesc); + await gov.connect(admin).setParticipantCount(100); + + return { gov, admin, alice, bob, charlie, dave, ministryId }; + } + + beforeEach(function () { + if (!hre.fhe.isPermittedEnvironment("MOCK")) this.skip(); + }); + + describe("Ministry Management", function () { + it("should create a ministry", async function () { + const { gov, ministryId } = await loadFixture(deployFixture); + const ministry = await gov.ministries(ministryId); + expect(ministry.active).to.be.true; + }); + + it("should reject duplicate ministry", async function () { + const { gov, admin, ministryId } = await loadFixture(deployFixture); + await expect( + gov.connect(admin).createMinistry( + ministryId, + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("dup")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("dup")) + ) + ).to.be.revertedWith("Ministry exists"); + }); + + it("should track ministry count", async function () { + const { gov, admin } = await loadFixture(deployFixture); + + const id2 = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("shadow-health")); + await gov.connect(admin).createMinistry( + id2, + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Health")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Health Ministry")) + ); + + expect(await gov.getMinistryCount()).to.equal(2); + }); + }); + + describe("Proposal Creation", function () { + it("should create a proposal", async function () { + const { gov, alice, ministryId } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Reform proposal")); + + await gov.connect(alice).createProposal(contentHash, ministryId, 100); + + const proposal = await gov.getProposal(0); + expect(proposal.contentHash).to.equal(contentHash); + expect(proposal.ministryId).to.equal(ministryId); + expect(proposal.voterCount).to.equal(0); + expect(proposal.status).to.equal(0); // Active + }); + }); + + describe("Anonymous Voting", function () { + it("should cast encrypted votes", async function () { + const { gov, alice, bob, charlie, ministryId } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Vote test")); + + await gov.connect(alice).createProposal(contentHash, ministryId, 1000); + + // Alice votes yes (1) + const nullifier1 = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("alice-secret-0")); + const [encYes] = await fhe.encrypt([Encryptable.uint32(1n)]); + await gov.connect(alice).castVote(0, encYes, nullifier1); + + // Bob votes no (0) + const nullifier2 = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("bob-secret-0")); + const [encNo] = await fhe.encrypt([Encryptable.uint32(0n)]); + await gov.connect(bob).castVote(0, encNo, nullifier2); + + // Charlie votes yes (1) + const nullifier3 = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("charlie-secret-0")); + const [encYes2] = await fhe.encrypt([Encryptable.uint32(1n)]); + await gov.connect(charlie).castVote(0, encYes2, nullifier3); + + const proposal = await gov.getProposal(0); + expect(proposal.voterCount).to.equal(3); + }); + + it("should prevent double-voting with same nullifier", async function () { + const { gov, alice, ministryId } = await loadFixture(deployFixture); + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Double vote test")); + + await gov.connect(alice).createProposal(contentHash, ministryId, 1000); + + const nullifier = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("alice-secret")); + const [enc] = await fhe.encrypt([Encryptable.uint32(1n)]); + await gov.connect(alice).castVote(0, enc, nullifier); + + // Same nullifier = rejected + const [enc2] = await fhe.encrypt([Encryptable.uint32(1n)]); + await expect(gov.connect(alice).castVote(0, enc2, nullifier)) + .to.be.revertedWith("Already voted"); + }); + }); + + describe("Tally and Finalization", function () { + it("should tally votes and finalize (pass scenario)", async function () { + const { gov, admin, alice, bob, charlie, dave, ministryId } = await loadFixture(deployFixture); + + // Set low quorum for test + await gov.connect(admin).setQuorum(100); // 1% + + const contentHash = hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Tally test")); + await gov.connect(alice).createProposal(contentHash, ministryId, 5); + + // 3 yes, 1 no + const [ey1] = await fhe.encrypt([Encryptable.uint32(1n)]); + await gov.connect(alice).castVote(0, ey1, hre.ethers.keccak256(hre.ethers.toUtf8Bytes("n1"))); + + const [ey2] = await fhe.encrypt([Encryptable.uint32(1n)]); + await gov.connect(bob).castVote(0, ey2, hre.ethers.keccak256(hre.ethers.toUtf8Bytes("n2"))); + + const [ey3] = await fhe.encrypt([Encryptable.uint32(1n)]); + await gov.connect(charlie).castVote(0, ey3, hre.ethers.keccak256(hre.ethers.toUtf8Bytes("n3"))); + + const [en1] = await fhe.encrypt([Encryptable.uint32(0n)]); + await gov.connect(dave).castVote(0, en1, hre.ethers.keccak256(hre.ethers.toUtf8Bytes("n4"))); + + // Mine blocks past deadline + await hre.network.provider.send("hardhat_mine", ["0x10"]); + + // Start tally + await gov.startTally(0); + + // Finalize + await gov.finalize(0); + + const proposal = await gov.getProposal(0); + expect(proposal.status).to.equal(2); // Passed + + const tally = await gov.getTally(0); + expect(tally.yesVotes).to.equal(3); + expect(tally.noVotes).to.equal(1); + }); + }); + + describe("Access Control", function () { + it("should reject non-admin ministry creation", async function () { + const { gov, alice } = await loadFixture(deployFixture); + await expect( + gov.connect(alice).createMinistry( + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("rogue")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Rogue")), + hre.ethers.keccak256(hre.ethers.toUtf8Bytes("Rogue Ministry")) + ) + ).to.be.revertedWith("Not admin"); + }); + }); +}); diff --git a/examples/shadow-governance/tsconfig.json b/examples/shadow-governance/tsconfig.json new file mode 100644 index 0000000..574e785 --- /dev/null +++ b/examples/shadow-governance/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +}