mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-26 23:16:08 +00:00
feat: add FHE example apps for all Pars PIPs
- data-seal: Verifiable Data Integrity Seal (PIP-0010/LP-0535) FHE-encrypted tamper-proof sealing with Public/ZK/Private modes, batch sealing, homomorphic verification - content-provenance: AI & Media Content Provenance (PIP-0011/LP-7110) Model manifests, output attestation via homomorphic comparison, media derivation DAGs - encrypted-crdt: Encrypted CRDT (PIP-0013/LP-6500) LWW-Register with FHE values, OR-Set with tag-based add/remove, Lamport timestamp conflict resolution, deterministic merge - shadow-governance: Shadow Government Protocol (PIP-7010) Anonymous ministries, encrypted voting with homomorphic tallying, nullifier-based anti-fraud, quorum enforcement Each example includes Solidity contracts, Hardhat tests, CLI tasks, and README linking to corresponding PIP and LP specifications.
This commit is contained in:
@@ -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 <addr> --hash 0x... --type 0
|
||||
|
||||
# Register AI output
|
||||
npx hardhat task:register-content --contract <addr> --hash 0x... --type 1 --model 42
|
||||
|
||||
# Attest output origin
|
||||
npx hardhat task:attest-output --contract <addr> --output 1 --model 42
|
||||
|
||||
# View provenance chain
|
||||
npx hardhat task:verify-provenance --contract <addr> --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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import "./deploy";
|
||||
import "./register-content";
|
||||
import "./attest-output";
|
||||
import "./verify-provenance";
|
||||
@@ -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()}`);
|
||||
});
|
||||
@@ -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).");
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
@@ -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 <address> --hash 0xabc...
|
||||
|
||||
# Verify a seal
|
||||
npx hardhat task:verify-seal --contract <address> --id 0 --tag 42
|
||||
|
||||
# Batch seal
|
||||
npx hardhat task:batch-seal --contract <address> --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)
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import "./deploy-seal";
|
||||
import "./seal";
|
||||
import "./verify-seal";
|
||||
import "./batch-seal";
|
||||
@@ -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)]);
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
@@ -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 <addr> --doc myDoc --field title --value 42 --timestamp 1
|
||||
|
||||
# Read a register
|
||||
npx hardhat task:get-register --contract <addr> --doc myDoc --field title
|
||||
|
||||
# Merge conflicting registers
|
||||
npx hardhat task:merge-registers --contract <addr> --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)
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import "./deploy-crdt";
|
||||
import "./set-register";
|
||||
import "./get-register";
|
||||
import "./merge-registers";
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
@@ -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 <addr> --content "Reform education" --ministry <id>
|
||||
|
||||
# Vote anonymously
|
||||
npx hardhat task:vote --contract <addr> --proposal 0 --choice yes --secret "my-secret"
|
||||
|
||||
# Tally after voting period
|
||||
npx hardhat task:tally --contract <addr> --proposal 0
|
||||
|
||||
# List ministries
|
||||
npx hardhat task:list-ministries --contract <addr>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import "./deploy-gov";
|
||||
import "./propose";
|
||||
import "./vote";
|
||||
import "./tally";
|
||||
import "./list-ministries";
|
||||
@@ -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()}`);
|
||||
}
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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.");
|
||||
}
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user