-
Notifications
You must be signed in to change notification settings - Fork 7
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
Update node contract #106
Update node contract #106
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,4 @@ | ||
with-expecter: True | ||
testonly: True | ||
dir: pkg/mocks | ||
mockname: "Mock{{.InterfaceName}}" | ||
outpkg: mocks | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,91 +1,162 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.13; | ||
|
||
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; | ||
import "@openzeppelin/contracts/access/Ownable.sol"; | ||
|
||
contract Nodes is Ownable { | ||
constructor() Ownable(msg.sender) {} | ||
/** | ||
A NFT contract for XMTP Node Operators. | ||
|
||
The deployer of this contract is responsible for minting NFTs and assinging them to node operators. | ||
|
||
All nodes on the network periodically check this contract to determine which nodes they should connect to. | ||
*/ | ||
contract Nodes is ERC721, Ownable { | ||
constructor() ERC721("XMTP Node Operator", "XMTP") Ownable(msg.sender) {} | ||
|
||
// uint16 counter so that we cannot create more than 65k IDs | ||
// The ERC721 standard expects the tokenID to be uint256 for standard methods unfortunately | ||
uint16 private _nodeIdCounter; | ||
|
||
// A node, as stored in the internal mapping | ||
struct Node { | ||
bytes signingKeyPub; | ||
string httpAddress; | ||
uint256 originatorId; | ||
bool isHealthy; | ||
// Maybe we want a TLS cert separate from the public key for MTLS authenticated connections? | ||
} | ||
|
||
event NodeUpdate( | ||
bytes publicKey, | ||
string httpAddress, | ||
uint256 originatorId, | ||
bool isHealthy | ||
); | ||
struct NodeWithId { | ||
uint16 nodeId; | ||
Node node; | ||
} | ||
|
||
// List of public keys | ||
bytes[] publicKeys; | ||
event NodeUpdated(uint256 nodeId, Node node); | ||
|
||
// Mapping of publicKey to node | ||
mapping(bytes => Node) public nodes; | ||
// Mapping of token ID to Node | ||
mapping(uint256 => Node) private _nodes; | ||
|
||
/** | ||
Add a node to the network | ||
Mint a new node NFT and store the metadata in the smart contract | ||
*/ | ||
function addNode( | ||
bytes calldata publicKey, | ||
address to, | ||
bytes calldata signingKeyPub, | ||
string calldata httpAddress | ||
) public onlyOwner returns (uint16) { | ||
uint16 nodeId = _nodeIdCounter; | ||
_mint(to, nodeId); | ||
_nodes[nodeId] = Node(signingKeyPub, httpAddress, true); | ||
_emitNodeUpdate(nodeId); | ||
_nodeIdCounter++; | ||
|
||
return nodeId; | ||
} | ||
|
||
/** | ||
Override the built in transferFrom function to block NFT owners from transferring | ||
node ownership. | ||
|
||
NFT owners are only allowed to update their HTTP address and MTLS cert. | ||
*/ | ||
function transferFrom( | ||
address from, | ||
address to, | ||
uint256 tokenId | ||
) public override { | ||
require( | ||
_msgSender() == owner(), | ||
"Only the contract owner can transfer Node ownership" | ||
); | ||
Comment on lines
+66
to
+69
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. Small nit, any reason this one uses a require statement, but 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. Only that I wanted a better error message, but agree the inconsistency feels weird |
||
super.transferFrom(from, to, tokenId); | ||
} | ||
|
||
/** | ||
Allow a NFT holder to update the HTTP address of their node | ||
*/ | ||
function updateHttpAddress( | ||
uint256 tokenId, | ||
string calldata httpAddress | ||
) public onlyOwner { | ||
) public { | ||
require( | ||
bytes(nodes[publicKey].httpAddress).length == 0, | ||
"Node already exists" | ||
_msgSender() == ownerOf(tokenId), | ||
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. I wonder if there are tradeoffs to allowing/encouraging nodes to update the HTTP address at any point, rather than going through a more heavyweight process via the DAO? For example, ensuring there is a graceful failover etc. Don't have a super strong opinion here 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. I don't really know. Wondering the same thing. Worth a discussion. |
||
"Only the owner of the Node NFT can update its http address" | ||
); | ||
_nodes[tokenId].httpAddress = httpAddress; | ||
_emitNodeUpdate(tokenId); | ||
} | ||
|
||
require(bytes(httpAddress).length != 0, "HTTP address is required"); | ||
/** | ||
The contract owner may update the health status of the node. | ||
|
||
nodes[publicKey] = Node({ | ||
httpAddress: httpAddress, | ||
originatorId: publicKeys.length + 1, | ||
isHealthy: true | ||
}); | ||
No one else is allowed to call this function. | ||
*/ | ||
function updateHealth(uint256 tokenId, bool isHealthy) public onlyOwner { | ||
// Make sure that the token exists | ||
_requireOwned(tokenId); | ||
_nodes[tokenId].isHealthy = isHealthy; | ||
_emitNodeUpdate(tokenId); | ||
} | ||
|
||
publicKeys.push(publicKey); | ||
/** | ||
Get a list of healthy nodes with their ID and metadata | ||
*/ | ||
function healthyNodes() public view returns (NodeWithId[] memory) { | ||
uint16 totalNodeCount = _nodeIdCounter; | ||
uint256 healthyCount = 0; | ||
|
||
emit NodeUpdate(publicKey, httpAddress, publicKeys.length, true); | ||
// First, count the number of healthy nodes | ||
for (uint256 i = 0; i < totalNodeCount; i++) { | ||
if (_nodeExists(i) && _nodes[i].isHealthy) { | ||
healthyCount++; | ||
} | ||
} | ||
|
||
// Create an array to store healthy nodes | ||
NodeWithId[] memory healthyNodesList = new NodeWithId[](healthyCount); | ||
uint256 currentIndex = 0; | ||
|
||
// Populate the array with healthy nodes | ||
for (uint16 i = 0; i < totalNodeCount; i++) { | ||
if (_nodeExists(i) && _nodes[i].isHealthy) { | ||
healthyNodesList[currentIndex] = NodeWithId({ | ||
nodeId: i, | ||
node: _nodes[i] | ||
}); | ||
currentIndex++; | ||
} | ||
} | ||
|
||
return healthyNodesList; | ||
} | ||
|
||
/** | ||
The contract owner can use this function to mark a node as unhealthy | ||
triggering all other nodes to stop replicating to/from this node | ||
Get all nodes regardless of their health status | ||
*/ | ||
function markNodeUnhealthy(bytes calldata publicKey) public onlyOwner { | ||
require( | ||
bytes(nodes[publicKey].httpAddress).length != 0, | ||
"Node does not exist" | ||
); | ||
nodes[publicKey].isHealthy = false; | ||
function allNodes() public view returns (NodeWithId[] memory) { | ||
uint16 totalNodeCount = _nodeIdCounter; | ||
NodeWithId[] memory allNodesList = new NodeWithId[](totalNodeCount); | ||
|
||
emit NodeUpdate( | ||
publicKey, | ||
nodes[publicKey].httpAddress, | ||
nodes[publicKey].originatorId, | ||
false | ||
); | ||
for (uint16 i = 0; i < totalNodeCount; i++) { | ||
allNodesList[i] = NodeWithId({nodeId: i, node: _nodes[i]}); | ||
} | ||
|
||
return allNodesList; | ||
} | ||
|
||
/** | ||
The contract owner can use this function to mark a node as healthy | ||
triggering all other nodes to | ||
Get a node's metadata by ID | ||
*/ | ||
function markNodeHealthy(bytes calldata publicKey) public onlyOwner { | ||
require( | ||
bytes(nodes[publicKey].httpAddress).length != 0, | ||
"Node does not exist" | ||
); | ||
nodes[publicKey].isHealthy = true; | ||
function getNode(uint256 tokenId) public view returns (Node memory) { | ||
_requireOwned(tokenId); | ||
return _nodes[tokenId]; | ||
} | ||
|
||
emit NodeUpdate( | ||
publicKey, | ||
nodes[publicKey].httpAddress, | ||
nodes[publicKey].originatorId, | ||
true | ||
); | ||
function _emitNodeUpdate(uint256 tokenId) private { | ||
emit NodeUpdated(tokenId, _nodes[tokenId]); | ||
} | ||
|
||
function _nodeExists(uint256 tokenId) private view returns (bool) { | ||
address owner = _ownerOf(tokenId); | ||
return owner != address(0); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
// SPDX-License-Identifier: UNLICENSED | ||
pragma solidity ^0.8.13; | ||
|
||
import {Test, console} from "forge-std/Test.sol"; | ||
import {Nodes} from "../src/Nodes.sol"; | ||
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; | ||
|
||
contract NodesTest is Test { | ||
Nodes public nodes; | ||
|
||
function setUp() public { | ||
nodes = new Nodes(); | ||
} | ||
|
||
function _genBytes(uint32 length) internal pure returns (bytes memory) { | ||
bytes memory message = new bytes(length); | ||
for (uint256 i = 0; i < length; i++) { | ||
message[i] = bytes1(uint8(i % 256)); | ||
} | ||
|
||
return message; | ||
} | ||
|
||
function _genString(uint32 length) internal pure returns (string memory) { | ||
return string(_genBytes(length)); | ||
} | ||
|
||
function _randomNode( | ||
bool isHealthy | ||
) internal pure returns (Nodes.Node memory) { | ||
return | ||
Nodes.Node({ | ||
signingKeyPub: _genBytes(32), | ||
httpAddress: _genString(32), | ||
isHealthy: isHealthy | ||
}); | ||
} | ||
|
||
function test_canAddNode() public { | ||
Nodes.Node memory node = _randomNode(true); | ||
|
||
address operatorAddress = vm.randomAddress(); | ||
|
||
uint16 nodeId = nodes.addNode( | ||
operatorAddress, | ||
node.signingKeyPub, | ||
node.httpAddress | ||
); | ||
|
||
vm.assertEq(nodes.ownerOf(nodeId), operatorAddress); | ||
vm.assertEq(nodes.getNode(nodeId).signingKeyPub, node.signingKeyPub); | ||
vm.assertEq(nodes.getNode(nodeId).httpAddress, node.httpAddress); | ||
vm.assertEq(nodes.getNode(nodeId).isHealthy, true); | ||
} | ||
|
||
function test_canAddMultiple() public { | ||
Nodes.Node memory node1 = _randomNode(true); | ||
Nodes.Node memory node2 = _randomNode(true); | ||
Nodes.Node memory node3 = _randomNode(true); | ||
|
||
address operator1 = vm.randomAddress(); | ||
address operator2 = vm.randomAddress(); | ||
address operator3 = vm.randomAddress(); | ||
|
||
uint16 node1Id = nodes.addNode( | ||
operator1, | ||
node1.signingKeyPub, | ||
node1.httpAddress | ||
); | ||
nodes.addNode(operator2, node2.signingKeyPub, node2.httpAddress); | ||
nodes.addNode(operator3, node3.signingKeyPub, node3.httpAddress); | ||
|
||
Nodes.NodeWithId[] memory healthyNodes = nodes.healthyNodes(); | ||
vm.assertTrue(healthyNodes.length == 3); | ||
|
||
nodes.updateHealth(node1Id, false); | ||
healthyNodes = nodes.healthyNodes(); | ||
vm.assertTrue(healthyNodes.length == 2); | ||
} | ||
|
||
function test_canMarkUnhealthy() public { | ||
Nodes.Node memory node = _randomNode(true); | ||
address operator = vm.randomAddress(); | ||
|
||
uint16 nodeId = nodes.addNode( | ||
operator, | ||
node.signingKeyPub, | ||
node.httpAddress | ||
); | ||
|
||
nodes.updateHealth(nodeId, false); | ||
|
||
vm.assertEq(nodes.getNode(nodeId).isHealthy, false); | ||
vm.assertEq(nodes.healthyNodes().length, 0); | ||
} | ||
|
||
function testFail_ownerCannotUpdateHealth() public { | ||
vm.expectRevert(Ownable.OwnableUnauthorizedAccount.selector); | ||
Nodes.Node memory node = _randomNode(true); | ||
address operator = vm.randomAddress(); | ||
|
||
uint16 nodeId = nodes.addNode( | ||
operator, | ||
node.signingKeyPub, | ||
node.httpAddress | ||
); | ||
|
||
vm.prank(operator); | ||
nodes.updateHealth(nodeId, false); | ||
} | ||
|
||
function testFail_ownerCannotTransfer() public { | ||
Nodes.Node memory node = _randomNode(true); | ||
address operator = vm.randomAddress(); | ||
|
||
uint16 nodeId = nodes.addNode( | ||
operator, | ||
node.signingKeyPub, | ||
node.httpAddress | ||
); | ||
|
||
vm.prank(operator); | ||
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.
|
||
nodes.safeTransferFrom(operator, vm.randomAddress(), uint256(nodeId)); | ||
} | ||
|
||
function test_canChangeHttpAddress() public { | ||
Nodes.Node memory node = _randomNode(true); | ||
address operator = vm.randomAddress(); | ||
|
||
uint16 nodeId = nodes.addNode( | ||
operator, | ||
node.signingKeyPub, | ||
node.httpAddress | ||
); | ||
|
||
vm.prank(operator); | ||
nodes.updateHttpAddress(nodeId, "new-http-address"); | ||
|
||
vm.assertEq(nodes.getNode(nodeId).httpAddress, "new-http-address"); | ||
} | ||
|
||
function testFail_cannotChangeOtherHttpAddress() public { | ||
vm.expectRevert( | ||
"Only the owner of the Node NFT can update its http address" | ||
); | ||
|
||
Nodes.Node memory node = _randomNode(true); | ||
address operator = vm.randomAddress(); | ||
|
||
uint16 nodeId = nodes.addNode( | ||
operator, | ||
node.signingKeyPub, | ||
node.httpAddress | ||
); | ||
|
||
vm.prank(vm.randomAddress()); | ||
nodes.updateHttpAddress(nodeId, "new-http-address"); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should not allow a node to have node ID 0, because an originator SID of 0 means that the originator is the blockchain. Wondering if we should pre-increment _nodeIdCounter rather than post-incrementing it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a comment somewhere about this. I kinda think we should start at 1000 just to be safe. Would let us partition the smart contracts later if we wanted to.