👋
Safety and security nets are in place while Portrait is in beta. Read more about the security measures in place.
Spec is not final. The Portrait Protocol is still in development and subject to change. This document is intended to provide an overview of the protocol's design and functionality. The spec is open to contributions and feedback.

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

  1. 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.
  2. Openness: Waku is a permissionless network, meaning that anyone can participate in the network.
  3. 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:

  1. 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.
  2. 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.
  3. An onchain registry to associate hosting nodes with Portrait IDs.

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

  1. Posting updates: A node posts updates to the network when the data associated with a Portrait changes.
  2. Listening for updates: A node listens for updates to the data associated with Portraits on the network.

As a client

  1. Requesting data: A client requests the data associated with a Portrait when it is unavailable on the network via caching nodes.
  2. 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 of 84532.
  • If there is a portraitObject.metadata.previousPortraitHash key, the portraitObject.metadata.blockNumber must be higher than the portraitObject.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}
  • If there is a portraitObject.metadata.previousPortraitHash key, the portraitObject.metadata.portraitId must match the portraitId 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 the portraitId from the topic.
  • The portraitSigner must have signed the portraitSignature 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 the portraitId at the portraitObject.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 the portraitId stored in the PortraitStateRegistry 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 of 84532.
  • The portraitObject.metadata.blockNumber must be lower than the nodeBlockNumber
    • 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 the nodeBlockNumber. In addition, the nodeAddress and nodeSignature must be a null value.
      • If the block number is lower than a valid Portrait with the same portraitId, the node must reject the request.
  • 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 the portraitId from the topic.
  • The nodeAddress must be the signer of the nodeSignature.
  • The nodeSignature must adhere to the Node Signature Message Structure.
  • The portraitSigner must have signed the portraitSignature.
  • The portraitSigner must be the owner or delegate of the portraitId at the portraitObject.metadata.blockNumber.
  • The CID of the portraitObject must be equal to the CID of the portraitId stored in the PortraitStateRegistry 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 and updates-${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 the nodeSignature.
  • The nodeSignature must cover nodeState, hostedPortraitIds, nodeBlockNumber.
    • Exact structure to be announced.
  • The hostedPortraitIds must be a list of portraitId 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 a portraitId in the PortraitNodeRegistry 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

  1. Fetch cache from [POST] Serving a Portrait to a Client.
  2. Filter by latest timestamp, and verify the signatures.
  3. 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.


Footnotes

  1. 11/WAKU2-RELAY (opens in a new tab) & 13/WAKU2-STORE (opens in a new tab)