Hosting Nodes
Hosting nodes are responsible for hosting the data associated with Portraits and ensuring that the data is available to users. Hosting nodes store the data associated with Portraits and distribute the data to users on the network. Hosting nodes communicate with each other to ensure that the data is available and up-to-date.
Architecture
Hosting nodes are designed to be lightweight and efficient, with the goal of making it possible for any user to continuously run a node on their device without requiring significant resources. Hosting nodes are responsible for hosting the data associated with Portraits and ensuring that the data is available to users. Hosting nodes communicate with each other over the Waku network to distribute the data associated with Portraits and ensure that the data is available and up-to-date.
Transport Layer
Hosting nodes use a peer-to-peer network called Waku to communicate with each other.
What is Waku?
Waku is a decentralized communication protocol that is used by hosting nodes to communicate with each other. Waku is the continuation of Whisper (opens in a new tab), originally developed by Gavin Wood. Waku is built on top of libp2p (opens in a new tab).
Vitalik Buterin recognizes the importance of Waku in his recent blog post Make Ethereum Cyberpunk Again (opens in a new tab).
Learn more about Waku at waku.org (opens in a new tab)
Why Portrait uses Waku
- Accessibility: A Waku Light Node is designed to be ran in a browser environment for clients who spend most of their time offline or disconnected from the internet and only occasionally connect to the network.
- Openness: Waku is a permissionless network, meaning that anyone can participate in the network.
- Compatibility: Portraits are compressed to fit within the constraints of Waku messages, making it possible to distribute the data associated with Portraits over the Waku network.
Caching nodes
Caching nodes are responsible for caching the data associated with Portraits transmitted by hosting nodes and making the data available to users on the network. Caching nodes are Waku Relay Nodes that run the Store Protocol1, which allows them to cache and relay messages on the network. Caching nodes are not responsible for hosting the data associated with Portraits but instead focus on caching and relaying messages on the network.
Hosting nodes extend Waku Light Nodes
Hosting nodes extend Waku Light Nodes (opens in a new tab) with the following features:
- A local key-value store for efficient data storage associated with Portraits on the user's device.
- Desktop apps store data in local app storage.
- Progressive Web Apps (PWAs) store data in a local IndexedDB.
- Signature-based messaging to ensure the integrity and authenticity of the data associated with Portraits.
- A hosting nodes signs the data associated with a Portrait before sending it to the network.
- A minimum of once per 24 hours, a hosting node posts the latest state of their local storage to the network.
- This measure increases the availability of data on the network in case the state of caching node is deleted, corrupted or unavailable.
- An onchain registry to associate hosting nodes with Portrait IDs.
- The PortraitNodeRegistry contract records the link between a hosting node and a Portrait ID.
Interacting with the network
There are four main ways to interact with the network. There is a distinction between interacting with the network as a node and as a client.
As a node
- Posting updates: A node posts updates to the network when the data associated with a Portrait changes.
- Listening for updates: A node listens for updates to the data associated with Portraits on the network.
As a client
- Requesting data: A client requests the data associated with a Portrait when it is unavailable on the network via caching nodes.
- Pinging a node: A client pings a node to discover if it is online.
Specifications
Message Format
Hosting nodes use the 14/WAKU2-MESSAGE (opens in a new tab) standard to communicate with each other.
Topics
Hosting nodes use the [23/WAKU2-TOPICS(https://rfc.vac.dev/waku/informational/23/topics (opens in a new tab)) standard to categorize and distribute the data associated with Portraits.
Hosting nodes use the following topics to categorize and distribute the data associated with Portraits:
/portrait_test/1/updates-all/proto
: To subscribe to updates from all Portraits/portrait_test/1/updates-${portraitId}/proto
: Subscribe to updates from a specific Portrait/portrait_test/1/latest-${portraitId}/proto
: Obtain the latest Portrait for a specific Portrait/portrait_test/1/requests-${portraitId}/proto
: Request latest Portrait for a specific Portrait/portrait_test/1/ping-all/proto
: Ping all nodes (a node may not respond if it has sent a ping in the last 24 hours, depending on the sender of the ping request)/portrait_test/1/ping-${nodeAddress}
: Ping a specific node (a node may not respond if it has sent a ping in the last 24 hours, depending on the sender of the ping request)
Node Signature Message Structure
Certain messages require a signature from the hosting node to verify the integrity and authenticity of data related to Portraits and to confirm the hosting node's identity. This signature, referred to as nodeSignature
, is generated using the hosting node's private key.
Non-standard Packed Mode
The node signature message requirements utilize Solidity's Non-standard Packed Mode, as defined in the abi.encodePacked()
function. This mode concatenates types shorter than 32 bytes directly without padding, encodes dynamic types in-place without their length, and pads array elements but still encodes them in-place. Refer to the Solidity ABI Specification for detailed information.
Message Requirements
The message that requires the node signature must include:
- Portrait Object (string): A stringified JSON object representing the Portrait.
- Block Number (uint256): The block number on the blockchain network defined at Chain ID.
- Chain ID (uint256): The ID of the blockchain network.
Example
To generate the message to be signed, use the ethers.solidityPackedKeccak256
function:
import { ethers } from 'ethers';
const PORTRAIT_OBJECT = '{...}'; // The Portrait object as a stringified JSON object.
const BLOCK_NUMBER = 12345; // The block number on the blockchain network.
const CHAIN_ID = 84532; // The ID of the blockchain network, in this case Base Sepolia.
const messageToBeSigned = ethers.solidityPackedKeccak256(
['string', 'uint256', 'uint256'],
[PORTRAIT_OBJECT, BLOCK_NUMBER, CHAIN_ID],
);
POST and GET
In the context of the Portrait Protocol, [POST] requests are used to send data to the network, while [GET] requests are used to retrieve data from the network. This is similar to the HTTP protocol, where POST requests are used to send data to a server, and GET requests are used to retrieve data from a server.
These definitions are only added to provide clarity and are not related to the HTTP protocol nor any interactions with the Portrait Protocol.
[POST] Posting an Update
To post an update for a specific Portrait as a delegate or owner, use the following standard, replacing {$portraitId}
with the portrait's ID:
Content Topic
const postUpdateContentTopic = `/portrait_test/1/updates-${portraitId}/proto`;
const postUpdateToAllContentTopic = `/portrait_test/1/updates-all/proto`;
Message Format
const PortraitUpdateMessage = new protobuf.Type('PortraitUpdateMessage')
.add(new protobuf.Field('portraitObject', 1, 'string'))
.add(new protobuf.Field('portraitSigner', 2, 'string'))
.add(new protobuf.Field('portraitSignature', 3, 'string'));
Message Requirements
- The
portraitObject.metadata.chainId
must match the chain ID of the network. The current network is Base Sepolia, with a chain ID of84532
. - If there is a
portraitObject.metadata.previousPortraitHash
key, theportraitObject.metadata.blockNumber
must be higher than theportraitObject.metadata.blockNumber
of the previous Portrait.- The previous Portrait is fetched over IPFS as the
portraitObject.metadata.previousPortraitHash
is the CID of the previous Portrait:https://portrait.host/ipfs/${portraitObject.metadata.previousPortraitHash}
- The previous Portrait is fetched over IPFS as the
- If there is a
portraitObject.metadata.previousPortraitHash
key, theportraitObject.metadata.portraitId
must match theportraitId
of the previous Portrait. - The
portraitObject.metadata.blockNumber
must be lower than the current block number.- The node cannot receive the Portrait before the creator (owner or delegate) has published it.
- The
portraitObject.metadata.nodeBlockNumber
must be obtained from the blockchain network where the contracts are deployed, in this case Base Sepolia. - The
portraitObject.metadata.portraitId
must match theportraitId
from the topic. - The
portraitSigner
must have signed theportraitSignature
according to the following structure:import { signMessage } from 'ethers'; const portraitSignature = await wallet.signMessage(JSON.stringify(portraitObject));
- The
portraitSigner
must be the owner or delegate of theportraitId
at theportraitObject.metadata.blockNumber
. - When updating a Portrait, the latest version of the Portrait object must also be sent to the
updates-all
topic. - The CID of the
portraitObject
must be equal to the CID of theportraitId
stored in thePortraitStateRegistry
contract.
[POST] Serving a Portrait to a Client
To serve a Portrait to a client as a hosting node, use the following standard, replacing {$portraitId}
with the portrait's ID:
Content Topic
const getLatestPortraitContentTopic = `/portrait_test/1/latest-${portraitId}/proto`;
Message Format
const PortraitLatestMessage = new protobuf.Type('PortraitLatestMessage')
.add(new protobuf.Field('portraitObject', 1, 'string'))
.add(new protobuf.Field('portraitSigner', 2, 'string'))
.add(new protobuf.Field('portraitSignature', 3, 'string'))
.add(new protobuf.Field('nodeBlockNumber', 4, 'uint64'))
.add(new protobuf.Field('nodeAddress', 5, 'string'))
.add(new protobuf.Field('nodeSignature', 6, 'string'));
Message Requirements
- The
portraitObject.metadata.chainId
must match the chain ID of the network. The current network is Base Sepolia, with a chain ID of84532
. - The
portraitObject.metadata.blockNumber
must be lower than thenodeBlockNumber
- The node cannot receive the Portrait before the creator (owner or delegate) has published it.
- Exception: If the portraitObject is an updated version of the Portrait, the
portraitObject.metadata.blockNumber
must be equal to thenodeBlockNumber
. In addition, thenodeAddress
andnodeSignature
must be anull
value.- If the block number is lower than a valid Portrait with the same
portraitId
, the node must reject the request.
- If the block number is lower than a valid Portrait with the same
- The
nodeBlockNumber
must be obtained from the blockchain network where the contracts are deployed, in this case Base Sepolia. - The
portraitObject.metadata.portraitId
must match theportraitId
from the topic. - The
nodeAddress
must be the signer of thenodeSignature
. - The
nodeSignature
must adhere to the Node Signature Message Structure. - The
portraitSigner
must have signed theportraitSignature
. - The
portraitSigner
must be the owner or delegate of theportraitId
at theportraitObject.metadata.blockNumber
. - The CID of the
portraitObject
must be equal to the CID of theportraitId
stored in thePortraitStateRegistry
contract.
Note
- The
latest-${portraitId}
topic is used to serve the latest Portrait to a client, where any hosting node can serve the latest Portrait. - The
updates-all
andupdates-${portraitId}
topics are used to publish updates to the network by owners or delegates of a Portrait.
[POST] Requesting a Portrait from a Node
To request a Portrait from a node, use the following standard, replacing {$portraitId}
with the portrait's ID:
Content Topic
const requestLatestPortraitContentTopic = `/portrait_test/1/requests-${portraitId}/proto`;
Message Format
const PortraitRequestMessage = new protobuf.Type('PortraitRequestMessage')
.add(new protobuf.Field('portraitId', 1, 'uint64'))
.add(new protobuf.Field('requestBlockNumber', 2, 'uint64'));
Message Requirements
- The
requestBlockNumber
must be obtained from the blockchain network where the contracts are deployed, in this case Base Sepolia. - Should only be used if the
latest-${portraitId}
topic does not return a Portrait. - Concurrent to the request, the client must subscribe to the
latest-${portraitId}
topic to receive the requested Portrait. - See the Message Requirements section for additional requirements.
[POST] Pinging as a Node
To ping as a node, use the following standard, replacing {$nodeAddress}
with the node's address:
Content Topic
const pingAllNodesContentTopic = `/portrait_test/1/ping-all/proto`;
const pingNodeContentTopic = `/portrait_test/1/ping-${nodeAddress}/proto`;
Message Format
const PortraitPingMessage = new protobuf.Type('PortraitPingMessage')
.add(new protobuf.Field('nodeState', 1, 'string'))
.add(new protobuf.Field('hostedPortraitIds', 2, 'string'))
.add(new protobuf.Field('nodeBlockNumber', 3, 'uint64'))
.add(new protobuf.Field('nodeAddress', 4, 'string'))
.add(new protobuf.Field('nodeSignature', 5, 'string'));
Message Requirements
- The
nodeAddress
must be the signer of thenodeSignature
. - The
nodeSignature
must covernodeState
,hostedPortraitIds
,nodeBlockNumber
.- Exact structure to be announced.
- The
hostedPortraitIds
must be a list ofportraitId
that the node is hosting.- Exact structure to be announced.
- The
nodeBlockNumber
must be obtained from the blockchain network where the contracts are deployed, in this case Base Sepolia. - The
nodeState
must be a string that represents the state of the node.- Exact structure to be announced.
- The
nodeAddress
must be mapped to aportraitId
in thePortraitNodeRegistry
contract. nodeBlockNumber
must be mined within the last 24 hours.
[GET] Subscribing to and listening for Updates
Listen for messages coming from [POST] Posting an Update
[GET] Fetching the Latest Portrait
- Fetch cache from [POST] Serving a Portrait to a Client.
- Filter by latest timestamp, and verify the signatures.
- If no data is available, request the data from [POST] Requesting a Portrait from a Node.
[GET] Fetching last seen from a Node
Fetch cache from [POST] Pinging as a Node.