-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement FileRegistry contract #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
web3buidlerz
wants to merge
7
commits into
main
Choose a base branch
from
feat/x402/file-registry
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
229d5a1
feat: implement FileRegistry contract
web3buidlerz e786b18
Merge branch 'main' into feat/x402/file-registry
web3buidlerz 2cf272a
feat: add delist capabillities
web3buidlerz 9ee06bf
feat: validate contentHash and mimeType on file registration
web3buidlerz e4a2473
feat: cap getFiles page size at MAX_PAGE_SIZE
web3buidlerz 1f1a24d
fix: deployment script
web3buidlerz 5d261f2
chore: ditch comment
web3buidlerz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,288 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity ^0.8.28; | ||
|
|
||
| /** | ||
| * @title FileRegistry | ||
| * @notice On-chain source of truth for an x402 "pay-per-use" file marketplace. | ||
| * | ||
| * Each registered file points at an object stored off-chain in a private, | ||
| * S3-compatible bucket (MinIO in this template). The registry never holds the | ||
| * file bytes or any funds; it only records who owns a file, how much it costs, | ||
| * whether it is public, and where to find it. | ||
| * | ||
| * Access is enforced off-chain by the resource server: | ||
| * - Public files are served to anyone. | ||
| * - Private files are gated behind an x402 payment of `priceTinybar` HBAR to | ||
| * the owner's `payToAccountId`, settled per-download. There is no allow-list: | ||
| * a file is made effectively private by setting an arbitrarily high price. | ||
| * - Delisted files are treated as not found (no downloads, no marketplace row). | ||
| * The owner may register the same `objectKey` again after delisting. | ||
| * | ||
| * The bucket is private and all reads flow through the server's presigned URLs, | ||
| * so storing `objectKey` on-chain does not leak the file contents. | ||
| */ | ||
| contract FileRegistry { | ||
| /// @notice Asset id used by x402 to denote native HBAR. Prices are quoted in tinybars (1 HBAR = 1e8 tinybars). | ||
| string public constant PAYMENT_ASSET = "0.0.0"; | ||
|
|
||
| /// @notice Upper bound on `getFiles` page size to keep view-call gas and return data predictable. | ||
| uint256 public constant MAX_PAGE_SIZE = 50; | ||
|
|
||
| /** | ||
| * @notice A registered file and its access terms. | ||
| * @param owner EVM address that registered the file and controls it. | ||
| * @param payToAccountId Hedera account id (e.g. "0.0.1234") that receives x402 payments for this file. | ||
| * @param priceTinybar Price per download in tinybars. Ignored when `isPublic` is true. | ||
| * @param isPublic When true, anyone may download for free; when false, payment is required per download. | ||
| * @param objectKey Key of the object in the private storage bucket. | ||
| * @param contentHash SHA-256 of the file contents, for integrity verification. | ||
| * @param name Human-readable file name. | ||
| * @param mimeType MIME type of the file. | ||
| * @param exists Whether this slot holds a registered file (guards against zeroed structs). | ||
| */ | ||
| struct FileItem { | ||
| address owner; | ||
| string payToAccountId; | ||
| uint256 priceTinybar; | ||
| bool isPublic; | ||
| string objectKey; | ||
| bytes32 contentHash; | ||
| string name; | ||
| string mimeType; | ||
| bool exists; | ||
| } | ||
|
|
||
| /// @notice Lookup of file metadata by file id. | ||
| mapping(bytes32 fileId => FileItem file) private _files; | ||
|
|
||
| /// @notice All registered file ids, in registration order, for enumeration. | ||
| bytes32[] private _fileIds; | ||
|
|
||
| /** | ||
| * @notice Emitted when a new file is registered. | ||
| * @dev `objectKey` and `payToAccountId` are unindexed so the full values are available to off-chain indexers. | ||
| */ | ||
| event FileRegistered( | ||
| bytes32 indexed fileId, | ||
| address indexed owner, | ||
| string objectKey, | ||
| string payToAccountId, | ||
| uint256 priceTinybar, | ||
| bool isPublic, | ||
| bytes32 contentHash, | ||
| string name, | ||
| string mimeType | ||
| ); | ||
|
|
||
| /// @notice Emitted when a file's price is updated. | ||
| event PriceChanged(bytes32 indexed fileId, uint256 oldPriceTinybar, uint256 newPriceTinybar); | ||
|
|
||
| /// @notice Emitted when a file's visibility is updated. | ||
| event VisibilityChanged(bytes32 indexed fileId, bool isPublic); | ||
|
|
||
| /// @notice Emitted when a file's payout account is updated. | ||
| event PayToChanged(bytes32 indexed fileId, string oldPayToAccountId, string newPayToAccountId); | ||
|
|
||
| /// @notice Emitted when an owner delists a file from the marketplace. | ||
| event FileDelisted(bytes32 indexed fileId, address indexed owner); | ||
|
|
||
| /// @notice Thrown when registering a file id that already exists. | ||
| error FileAlreadyRegistered(bytes32 fileId); | ||
|
|
||
| /// @notice Thrown when referencing a file id that has not been registered. | ||
| error FileNotFound(bytes32 fileId); | ||
|
|
||
| /// @notice Thrown when a non-owner attempts an owner-only action. | ||
| error NotFileOwner(bytes32 fileId, address caller); | ||
|
|
||
| /// @notice Thrown when a required string argument is empty. | ||
| error EmptyValue(string field); | ||
|
|
||
| /// @notice Thrown when `contentHash` is zero (a SHA-256 digest is required). | ||
| error InvalidContentHash(); | ||
|
|
||
| /// @dev Restricts a function to the owner of `fileId`. | ||
| modifier onlyFileOwner(bytes32 fileId) { | ||
| FileItem storage file = _files[fileId]; | ||
| if (!file.exists) revert FileNotFound(fileId); | ||
| if (file.owner != msg.sender) revert NotFileOwner(fileId, msg.sender); | ||
| _; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Register a new file and its access terms. | ||
| * @dev The file id is deterministic per (owner, objectKey), preventing duplicate registration of the | ||
| * same object by the same owner while letting different owners use independent object keys. | ||
| * @param objectKey Key of the object in the private storage bucket (must be non-empty). | ||
| * @param payToAccountId Hedera account id that receives payments for this file (must be non-empty). | ||
| * @param priceTinybar Price per download in tinybars (ignored while the file is public). | ||
| * @param isPublic Whether the file is freely downloadable. | ||
| * @param contentHash SHA-256 of the file contents (must be non-zero). | ||
| * @param name Human-readable file name. | ||
| * @param mimeType MIME type of the file (must be non-empty). | ||
| * @return fileId The id assigned to the newly registered file. | ||
| */ | ||
| function registerFile( | ||
| string calldata objectKey, | ||
| string calldata payToAccountId, | ||
| uint256 priceTinybar, | ||
| bool isPublic, | ||
| bytes32 contentHash, | ||
| string calldata name, | ||
| string calldata mimeType | ||
| ) external returns (bytes32 fileId) { | ||
| if (bytes(objectKey).length == 0) revert EmptyValue("objectKey"); | ||
| if (bytes(payToAccountId).length == 0) revert EmptyValue("payToAccountId"); | ||
| if (contentHash == bytes32(0)) revert InvalidContentHash(); | ||
| if (bytes(mimeType).length == 0) revert EmptyValue("mimeType"); | ||
|
|
||
| fileId = computeFileId(msg.sender, objectKey); | ||
| if (_files[fileId].exists) revert FileAlreadyRegistered(fileId); | ||
|
|
||
| _files[fileId] = FileItem({ | ||
| owner: msg.sender, | ||
| payToAccountId: payToAccountId, | ||
| priceTinybar: priceTinybar, | ||
| isPublic: isPublic, | ||
| objectKey: objectKey, | ||
| contentHash: contentHash, | ||
| name: name, | ||
| mimeType: mimeType, | ||
| exists: true | ||
| }); | ||
| _fileIds.push(fileId); | ||
|
|
||
| emit FileRegistered( | ||
| fileId, | ||
| msg.sender, | ||
| objectKey, | ||
| payToAccountId, | ||
| priceTinybar, | ||
| isPublic, | ||
| contentHash, | ||
| name, | ||
| mimeType | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Update the per-download price of a file. | ||
| * @param fileId Id of the file to update. | ||
| * @param newPriceTinybar New price in tinybars. | ||
| */ | ||
| function setPrice(bytes32 fileId, uint256 newPriceTinybar) external onlyFileOwner(fileId) { | ||
| FileItem storage file = _files[fileId]; | ||
| uint256 oldPriceTinybar = file.priceTinybar; | ||
| file.priceTinybar = newPriceTinybar; | ||
| emit PriceChanged(fileId, oldPriceTinybar, newPriceTinybar); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Update the visibility of a file. | ||
| * @param fileId Id of the file to update. | ||
| * @param isPublic New visibility flag. | ||
| */ | ||
| function setVisibility(bytes32 fileId, bool isPublic) external onlyFileOwner(fileId) { | ||
| _files[fileId].isPublic = isPublic; | ||
| emit VisibilityChanged(fileId, isPublic); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Update the Hedera account that receives payments for a file. | ||
| * @param fileId Id of the file to update. | ||
| * @param newPayToAccountId New payout account id (must be non-empty). | ||
| */ | ||
| function setPayToAccountId(bytes32 fileId, string calldata newPayToAccountId) external onlyFileOwner(fileId) { | ||
| if (bytes(newPayToAccountId).length == 0) revert EmptyValue("payToAccountId"); | ||
| FileItem storage file = _files[fileId]; | ||
| string memory oldPayToAccountId = file.payToAccountId; | ||
| file.payToAccountId = newPayToAccountId; | ||
| emit PayToChanged(fileId, oldPayToAccountId, newPayToAccountId); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Remove a file from the marketplace and block lookups by `fileId`. | ||
| * @dev Sets `exists` to false and removes `fileId` from the enumeration array so | ||
| * `getFiles` and `getFileCount` stay accurate. Off-chain objects in MinIO are not | ||
| * deleted; the owner must manage storage separately. The same `objectKey` may be | ||
| * registered again after delisting. | ||
| * @param fileId Id of the file to delist. | ||
| */ | ||
| function delistFile(bytes32 fileId) external onlyFileOwner(fileId) { | ||
| _files[fileId].exists = false; | ||
| _removeFromFileIds(fileId); | ||
| emit FileDelisted(fileId, msg.sender); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Deterministic file id for an (owner, objectKey) pair. | ||
| * @param owner Address registering the file. | ||
| * @param objectKey Storage object key. | ||
| * @return The file id. | ||
| */ | ||
| function computeFileId(address owner, string calldata objectKey) public pure returns (bytes32) { | ||
| return keccak256(abi.encodePacked(owner, objectKey)); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Fetch a registered file. | ||
| * @param fileId Id of the file. | ||
| * @return The file metadata. | ||
| */ | ||
| function getFile(bytes32 fileId) external view returns (FileItem memory) { | ||
| FileItem memory file = _files[fileId]; | ||
| if (!file.exists) revert FileNotFound(fileId); | ||
| return file; | ||
| } | ||
|
|
||
| /// @notice Number of active (non-delist) files in the marketplace. | ||
| function getFileCount() external view returns (uint256) { | ||
| return _fileIds.length; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Paginated list of files for marketplace listing. | ||
| * @dev Returns at most `limit` files starting at `offset`, capped at {@link MAX_PAGE_SIZE}. If `offset` is | ||
| * beyond the end, returns empty arrays. | ||
| * @param offset Index to start from. | ||
| * @param limit Maximum number of files to return (values above `MAX_PAGE_SIZE` are clamped). | ||
| * @return ids The file ids in the returned page. | ||
| * @return files The file metadata in the returned page. | ||
| */ | ||
| function getFiles( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this function can become expensive. Maybe we can have a MAX_PER_PAGE, but for the purposes of this template, we are ok |
||
| uint256 offset, | ||
| uint256 limit | ||
| ) external view returns (bytes32[] memory ids, FileItem[] memory files) { | ||
| if (limit > MAX_PAGE_SIZE) { | ||
| limit = MAX_PAGE_SIZE; | ||
| } | ||
|
|
||
| uint256 total = _fileIds.length; | ||
| if (offset >= total || limit == 0) { | ||
| return (new bytes32[](0), new FileItem[](0)); | ||
| } | ||
| uint256 end = offset + limit; | ||
| if (end > total) end = total; | ||
| uint256 size = end - offset; | ||
|
|
||
| ids = new bytes32[](size); | ||
| files = new FileItem[](size); | ||
| for (uint256 i = 0; i < size; i++) { | ||
| bytes32 id = _fileIds[offset + i]; | ||
| ids[i] = id; | ||
| files[i] = _files[id]; | ||
| } | ||
| } | ||
|
|
||
| /// @dev Removes `fileId` from `_fileIds` using swap-and-pop (O(n) scan; fine for template scale). | ||
| function _removeFromFileIds(bytes32 fileId) private { | ||
| uint256 len = _fileIds.length; | ||
| for (uint256 i = 0; i < len; i++) { | ||
| if (_fileIds[i] == fileId) { | ||
| _fileIds[i] = _fileIds[len - 1]; | ||
| _fileIds.pop(); | ||
| return; | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.