Skip to content

Commit 211b153

Browse files
committed
feat: ArtifactVault contract, deploy script, auto gas for Base L2
1 parent b9a7a82 commit 211b153

4 files changed

Lines changed: 268 additions & 1 deletion

File tree

contracts/vault/ArtifactVault.sol

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// SPDX-License-Identifier: AGPL-3.0
2+
pragma solidity ^0.8.21;
3+
4+
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
5+
import "@openzeppelin/contracts/access/Ownable.sol";
6+
import "@openzeppelin/contracts/utils/Counters.sol";
7+
8+
/**
9+
* @title ArtifactVault
10+
* @author Artifact Virtual
11+
* @notice On-chain enterprise snapshot manifest — each snapshot is an NFT
12+
*
13+
* Every daily snapshot of the Artifact Virtual enterprise workspace
14+
* gets encrypted, pinned to IPFS via our own Kubo node, and registered
15+
* here as a soulbound NFT. The tokenURI points to an IPFS metadata JSON
16+
* containing the archive CID, SHA-256 hash, file count, and timestamp.
17+
*
18+
* Non-transferable by design — these are attestations, not assets.
19+
* Only the deployer (Singularity pipeline) can mint.
20+
* Anyone can read the on-chain history.
21+
*
22+
* "If it computes, it will work."
23+
*/
24+
contract ArtifactVault is ERC721URIStorage, Ownable {
25+
using Counters for Counters.Counter;
26+
27+
Counters.Counter private _tokenIds;
28+
29+
/// @notice Snapshot metadata stored on-chain (compact, critical fields only)
30+
struct Snapshot {
31+
string archiveCid; // IPFS CID of the encrypted archive
32+
string metadataCid; // IPFS CID of the JSON metadata
33+
bytes32 archiveHash; // SHA-256 of the encrypted archive
34+
uint64 timestamp; // Unix timestamp of snapshot creation
35+
uint64 fileCount; // Number of files in snapshot
36+
uint64 sizeBytes; // Compressed archive size in bytes
37+
bool valid; // Can be invalidated if corruption detected post-mint
38+
}
39+
40+
/// @notice tokenId → Snapshot
41+
mapping(uint256 => Snapshot) public snapshots;
42+
43+
/// @notice Latest snapshot tokenId for quick lookup
44+
uint256 public latestSnapshotId;
45+
46+
/// @notice Total snapshots ever minted (including invalidated)
47+
uint256 public totalSnapshots;
48+
49+
// ─── Events ──────────────────────────────────────────────
50+
51+
event SnapshotMinted(
52+
uint256 indexed tokenId,
53+
string archiveCid,
54+
bytes32 archiveHash,
55+
uint64 timestamp,
56+
uint64 fileCount,
57+
uint64 sizeBytes
58+
);
59+
60+
event SnapshotInvalidated(uint256 indexed tokenId, string reason);
61+
62+
// ─── Errors ──────────────────────────────────────────────
63+
64+
error TransferDisabled();
65+
error SnapshotNotFound(uint256 tokenId);
66+
error AlreadyInvalidated(uint256 tokenId);
67+
68+
// ─── Constructor ─────────────────────────────────────────
69+
70+
constructor() ERC721("Artifact Vault", "VAULT") {
71+
// Deployer is owner — Singularity pipeline wallet
72+
}
73+
74+
// ─── Core ────────────────────────────────────────────────
75+
76+
/**
77+
* @notice Mint a new snapshot NFT
78+
* @param archiveCid IPFS CID of the encrypted archive
79+
* @param metadataCid IPFS CID of the metadata JSON (becomes tokenURI)
80+
* @param archiveHash SHA-256 digest of the encrypted archive
81+
* @param fileCount Number of files archived
82+
* @param sizeBytes Compressed archive size
83+
* @return tokenId The minted token ID
84+
*/
85+
function mintSnapshot(
86+
string calldata archiveCid,
87+
string calldata metadataCid,
88+
bytes32 archiveHash,
89+
uint64 fileCount,
90+
uint64 sizeBytes
91+
) external onlyOwner returns (uint256) {
92+
_tokenIds.increment();
93+
uint256 tokenId = _tokenIds.current();
94+
95+
_safeMint(owner(), tokenId);
96+
_setTokenURI(tokenId, string.concat("ipfs://", metadataCid));
97+
98+
snapshots[tokenId] = Snapshot({
99+
archiveCid: archiveCid,
100+
metadataCid: metadataCid,
101+
archiveHash: archiveHash,
102+
timestamp: uint64(block.timestamp),
103+
fileCount: fileCount,
104+
sizeBytes: sizeBytes,
105+
valid: true
106+
});
107+
108+
latestSnapshotId = tokenId;
109+
totalSnapshots++;
110+
111+
emit SnapshotMinted(
112+
tokenId,
113+
archiveCid,
114+
archiveHash,
115+
uint64(block.timestamp),
116+
fileCount,
117+
sizeBytes
118+
);
119+
120+
return tokenId;
121+
}
122+
123+
/**
124+
* @notice Invalidate a snapshot (corruption detected post-mint)
125+
* @param tokenId Token to invalidate
126+
* @param reason Human-readable reason
127+
*/
128+
function invalidateSnapshot(uint256 tokenId, string calldata reason)
129+
external
130+
onlyOwner
131+
{
132+
if (!_exists(tokenId)) revert SnapshotNotFound(tokenId);
133+
if (!snapshots[tokenId].valid) revert AlreadyInvalidated(tokenId);
134+
135+
snapshots[tokenId].valid = false;
136+
emit SnapshotInvalidated(tokenId, reason);
137+
}
138+
139+
// ─── Views ───────────────────────────────────────────────
140+
141+
/**
142+
* @notice Get the latest valid snapshot
143+
* @return snapshot The most recent valid Snapshot struct
144+
*/
145+
function latestSnapshot() external view returns (Snapshot memory) {
146+
return snapshots[latestSnapshotId];
147+
}
148+
149+
/**
150+
* @notice Get snapshot history (paginated)
151+
* @param offset Start tokenId (1-based)
152+
* @param limit Max results
153+
* @return result Array of Snapshot structs
154+
*/
155+
function getSnapshots(uint256 offset, uint256 limit)
156+
external
157+
view
158+
returns (Snapshot[] memory)
159+
{
160+
uint256 total = _tokenIds.current();
161+
if (offset > total || offset == 0) return new Snapshot[](0);
162+
163+
uint256 end = offset + limit;
164+
if (end > total + 1) end = total + 1;
165+
uint256 count = end - offset;
166+
167+
Snapshot[] memory result = new Snapshot[](count);
168+
for (uint256 i = 0; i < count; i++) {
169+
result[i] = snapshots[offset + i];
170+
}
171+
return result;
172+
}
173+
174+
// ─── Soulbound (non-transferable) ────────────────────────
175+
176+
/**
177+
* @dev Override to prevent all transfers. Snapshots are attestations.
178+
*/
179+
function _beforeTokenTransfer(
180+
address from,
181+
address to,
182+
uint256 tokenId,
183+
uint256 batchSize
184+
) internal virtual override {
185+
// Allow minting (from == address(0)) and burning (to == address(0))
186+
if (from != address(0) && to != address(0)) {
187+
revert TransferDisabled();
188+
}
189+
super._beforeTokenTransfer(from, to, tokenId, batchSize);
190+
}
191+
192+
// ─── Metadata ────────────────────────────────────────────
193+
194+
function contractURI() external pure returns (string memory) {
195+
return "ipfs://artifact-vault-collection-metadata";
196+
}
197+
}

deployment/vault/deployment.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"contract": "ArtifactVault",
3+
"address": "0x4E5785633f69Ba9675eb5f0F69EDE7A374e8846c",
4+
"deployer": "0x21E914dFBB137F7fEC896F11bC8BAd6BCCDB147B",
5+
"network": "base-sepolia",
6+
"chainId": 84532,
7+
"txHash": "0x77954929cf230a5897791e006d3ea58bb278770bbb68873a9f83ebf926abc732",
8+
"timestamp": "2026-03-15T07:23:37.429Z"
9+
}

hardhat.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const config: HardhatUserConfig = {
129129
"base-sepolia": {
130130
url: `https://base-sepolia.infura.io/v3/${INFURA_PROJECT_ID}`,
131131
accounts: [`0x${DEPLOYER_PRIVATE_KEY}`],
132-
gasPrice: parseInt(GAS_PRICE_TESTNET) * 1000000000,
132+
// Let the network auto-detect gas price (Base L2 is ~0.006 gwei)
133133
timeout: 60000,
134134
}
135135
} : {}),

scripts/deploy-vault.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { ethers } from "hardhat";
2+
3+
/**
4+
* Deploy ArtifactVault — Enterprise Snapshot NFT Contract
5+
*
6+
* Usage:
7+
* npx hardhat run scripts/deploy-vault.ts --network base-sepolia
8+
*/
9+
async function main() {
10+
const [deployer] = await ethers.getSigners();
11+
console.log("Deploying ArtifactVault with account:", deployer.address);
12+
13+
const balance = await ethers.provider.getBalance(deployer.address);
14+
console.log("Account balance:", ethers.formatEther(balance), "ETH");
15+
16+
if (balance < ethers.parseEther("0.001")) {
17+
throw new Error("Insufficient balance for deployment. Need at least 0.001 ETH.");
18+
}
19+
20+
const ArtifactVault = await ethers.getContractFactory("ArtifactVault");
21+
const vault = await ArtifactVault.deploy();
22+
await vault.waitForDeployment();
23+
24+
const address = await vault.getAddress();
25+
const deployTx = vault.deploymentTransaction();
26+
27+
console.log("ArtifactVault deployed to:", address);
28+
console.log("Owner:", await vault.owner());
29+
console.log("TX hash:", deployTx?.hash);
30+
console.log("Contract name:", await vault.name());
31+
console.log("Contract symbol:", await vault.symbol());
32+
console.log("Total snapshots:", (await vault.totalSnapshots()).toString());
33+
34+
// Save deployment info
35+
const fs = require("fs");
36+
const network = await ethers.provider.getNetwork();
37+
const deployInfo = {
38+
contract: "ArtifactVault",
39+
address: address,
40+
deployer: deployer.address,
41+
network: network.name,
42+
chainId: Number(network.chainId),
43+
txHash: deployTx?.hash || "",
44+
timestamp: new Date().toISOString()
45+
};
46+
47+
const deployDir = "./deployment/vault";
48+
if (!fs.existsSync(deployDir)) fs.mkdirSync(deployDir, { recursive: true });
49+
fs.writeFileSync(
50+
`${deployDir}/deployment.json`,
51+
JSON.stringify(deployInfo, null, 2)
52+
);
53+
console.log("Deployment info saved to", `${deployDir}/deployment.json`);
54+
}
55+
56+
main()
57+
.then(() => process.exit(0))
58+
.catch((error) => {
59+
console.error(error);
60+
process.exit(1);
61+
});

0 commit comments

Comments
 (0)