Skip to content
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

Merged
merged 1 commit into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .mockery.yaml
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
Expand Down
181 changes: 126 additions & 55 deletions contracts/src/Nodes.sol
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;
Copy link
Contributor

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?

Copy link
Contributor Author

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.

_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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit, any reason this one uses a require statement, but updateHealth uses public onlyOwner?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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),
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
}
}
2 changes: 1 addition & 1 deletion contracts/test/GroupMessage.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {GroupMessages} from "../src/GroupMessages.sol";

contract CounterTest is Test {
contract GroupMessagesTest is Test {
GroupMessages public groupMessages;

function setUp() public {
Expand Down
159 changes: 159 additions & 0 deletions contracts/test/Nodes.sol
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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vm.prank is cool. It just lets you change the msg.sender value to any other address for a test, so you can impersonate someone and see how the contract behaves.

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");
}
}
Loading
Loading