@futureverse/sylo-protocol-sdk
v0.0.0-pre-SM-337.1
Published
The Sylo protocol sdk provides simple, and easy to use methods for interacting with the Seekers Node infrastructure. Using the sdk client allows applications to leverage the decentralized communication services provided by Seeker Nodes.
Downloads
58
Maintainers
Keywords
Readme
Sylo Protocol SDK
The Sylo protocol sdk provides simple, and easy to use methods for interacting with the Seekers Node infrastructure. Using the sdk client allows applications to leverage the decentralized communication services provided by Seeker Nodes.
The SDK provides various APIs for relaying messages between Ethereum based accounts on The Root Network.
Install
NPM
npm install @futureverse/sylo-protocol-sdk
Yarn
yarn add @futureverse/sylo-protocol-sdk
Client Usage
Peer Key
An application first needs to create a Peer key for use with their client. A Peer key uniquely identifies a client within the network. Note: This key is for identifying a unique client. A user may be operating over multiple clients (e.g logged into multiple experiences simultaneously), however a user is uniquely identified by an ethereum account.
import * as E from "fp-ts/lib/Either";
import { PeerKey } from "@futureverse/sylo-protocol-sdk/lib/src/client";
export async function createPeerKey(): Promise<PeerKey> {
const peerKey = await PeerKey.create();
// serialize into bytes
const serialized = await PeerKey.encode(peerKey);
const deserializedR = await PeerKey.encode(serialized);
if (E.isLeft(deserializedR)) {
throw new Error(
`failed to deserialize peer key: ${JSON.stringify(deserializedR.left)}`
);
}
return peerKey;
}
After creating a peer key, it is recommended to persist the peer key in storage, and re-use the same peer key. This is because a client needs to authenticate itself using its peer key whenever it interacts with a new Seeker node. See Authentication for more details.
Creating the Client
The client exists to provides a simple to use interface for interacting with the Seeker network. It is responsible for discovering and managing connections to nodes, decoding and verification of messages, and correctly complying with the ticketing mechanisms of the protocol.
import * as ethers from "ethers";
import { Client, PeerKey } from "@futureverse/sylo-protocol-sdk/lib/src/client";
import { decodedOrThrow, Address } from "@futureverse/experience-sdk";
import { PORCINI_CONTRACTS } from "@futureverse/sylo-protocol-sdk/lib/src/ethers";
export async function createSDKClient(
wallet: ethers.Wallet,
provider: ethers.Provider
): Promise<Client> {
const prefix = "authentication_prefix";
const suffix = "authentication_suffix";
const account = decodedOrThrow(Address.decode(wallet.address));
const peerKey = await PeerKey.create();
return Client.create({
account,
signMessageFn: wallet.signMessage.bind(wallet),
peerKey,
network: PORCINI_CONTRACTS,
provider,
authenticationOpts: { prefix, suffix },
});
}
Client Options
account: This is the ethereum address that represents the user behind this client. It is a string that must comply with the ethereum address format, and the
create
method only accepts accounts of typeAddress
. The utility methoddecodedOrThrow
can be used convert a regular string into theAddress
type (will throw if passed an invalid address).signMessageFn: Messages relayed through the network must be signed by the sender, and the corresponding ticket attached to the message must be signed by the receiver. Signatures within the sylo-protocol always follow the etherum
personal_sign
format. This option represents a function that takes in data and returns a signature of that data, where the signer is theaccount
.peerKey: The peer key which will be used to identify the client.
network: This value represents a set of contract addresses for the sylo-protocol. The contracts have been deployed to mainnet and the porcini testnet. The sdk provides constant values for these deployments (
MAINNET
orPORCINI
).provider: A provider type from the ethers library which is used to connect to and read from the sylo-protocol contracts. This type does not need to have a signer attached, as the client will not attempt to perform any transactions on the users behalf.
authenticationOpts: An object containing the
prefix
andsuffix
authentication values. See Authentication for more details.
Starting the client
Before being able to use the client to send and receive messages, the client must first be started.
await client.start();
Starting the client will ensure the client is ready to receive messages. This involves discovering and connecting to the user's designated node for this epoch. It will also begin listening for new relay messages.
The client can then be stopped by calling client.stop
.
await client.stop();
Calling stop will close any underlying connections and message listeners.
Managing Deposits
Sending clients must pay Seeker Nodes to relay messages using SYLO tokens. Prior to being able to send messages through the network, the sending client must have Sylo tokens in the account's escrow and penalty. The escrow and penalty deposits are managed by a contract on-chain. When tickets are redeemed by a Seeker Node (a ticket is acquired by relaying messages), the ticket's value is deducted from the sending account's escrow. If there is insufficient escrow balance, then the balance is burned from the penalty instead.
There is a requirement that the sender's account must have at least enough escrow and penalty to pay for the relay of a single message, otherwise the Node will refuse to relay the message.
Connecting to the Ticketing contract
import * as ethers from "ethers";
import { PORCINI_CONTRACTS } from "@futureverse/sylo-protocol-sdk/lib/src/ethers";
import {
SyloTicketing__factory,
TicketingParameters__factory,
} from "@futureverse/sylo-protocol-sdk/lib/src/eth-types";
async function main() {
const provider = new ethers.JsonRpcProvider(
"wss://porcini.au.rootnet.app/ws"
);
// deposits are managed by the sylo ticketing contract
const ticketing = SyloTicketing__factory.connect(
PORCINI_CONTRACTS.syloTicketing,
provider
);
// ticket values are stored in the ticketing params contract
const ticketingParams = TicketingParameters__factory.connect(
PORCINI_CONTRACTS.ticketingParameters,
provider
);
}
Reading the current ticketing parameters
// read the current ticket value of a single relay
const winningTicketCost = await ticketingParams.faceValue();
const ticketWinChance = await ticketingParams.baseLiveWinProb();
// the expected cost of a single relay can be calculated by
// multiplying the ticket face value by its win chance.
const singleRelayCost =
(winningTicketCost * ticketWinChance) / (2n ** 128n - 1n); // 2^128-1 is the win chance denominator
Making deposits
// Depositing enough to pay for 10 winning tickets
await ticketing.depositEscrow(10n * winningTicketCost, senderAccount);
await ticketing.depositPenalty(winningTicketCost, senderAccount);
Relay
The relay api is used for sending 1 to 1 messages.
Sending
Parameters:
to
: The recipient of the message.message
: AUint8Array
value representing the message contents. This will be passed to thesignMessageFn
.
const message = `hello world`;
const sendResult = await client.relay.send(
decodedOrThrow(Address.decode("0x448c8e9e1816300Dd052e77D2A44c990A2807D15")),
Buffer.from(message)
);
if (E.isLeft(sendResult)) {
throw new Error(
`Expected send result to be ok: ${JSON.stringify(sendResult.left)}`
);
}
Calling client.relay.send
will return an Either
result, where a Left
result indicates failure. The expected errors for relay.send
are:
Receiving
To receive new relay messages, call the client.relay.pull
method.
const iterator = await receiverClient.relay.pull();
const relayMessageR = await iterator.next();
if (E.isLeft(relayMessageR.value)) {
throw new Error(`Unexpected relay error: ${JSON.stringify(relayMessageR)}`);
}
const relayMessage = relayMessageR.value.right;
// hello world
console.log(Buffer.from(relayMessage.message).toString());
Calling relay.pull
returns an iterator that emits values of type
Either<DeliveryError, RelayMessage>
.
type RelayMessage = {
sender: Address;
message: Uint8Array;
ticket: Ticket; // This is the protocol ticket and is handled by the sdk
node: Node;
};
If the iterator returns a value of DeliveryError
, it means that a new message
was detected, but it failed to be processed for one of the following reasons:
FailedToDecodeReceiverTicketSignature
- Failed to deserialize the signature
object sent by the sender of this message.
FailedToDecodeDelegatedReceiverAddress
- Failed to deserialize the delegated
receiver object specified by the sender.
Decoding failures tend to occur if the sending/receiving clients are using incompatible versions of the protocol sdk.
PubSub
The pubsub api is used to broadcast messages to receivers who are subscribed to a particular topic.
Publishing
Parameters:
topicId
- A string value for the topic of the messagemessage
- AUint8Array
value representing the contents of the message
const message = "hello world";
const topicId = "topic id";
const publishResult = await client.publish(topicId, message);
if (E.isLeft(publishResult)) {
throw new Error(`failed to publish ${JSON.stringify(publishResult.left)}`);
}
Subscribing
Parameters:
sender
- The ethereum address of the message sender. Clients must indicate which sender they want to listen to messages for.topicId
- A string value for this topic id.signal
- An AbortSignal used to cancel the subscription
const controller = new AbortController();
const iterator = await receiverClient.pubsub.subscribe(sender, topicId);
const pubsubMessageR = await iterator.next();
if (E.isLeft(pubsubMessageR.value)) {
throw new Error(`Unexpected relay error: ${JSON.stringify(relayMessageR)}`);
}
const pubsubMessage = pubsubMessageR.value.right;
// hello world
console.log(Buffer.from(pubsubMessage.message).toString());
Calling pubsub.subscribe
returns an iterator that emits values of type
Either<SubscribeError, PubsubMessage>
.
export type PubsubMessage = {
sender: Address;
topicId: string;
message: Uint8Array;
ticket: MultiReceiverTicket;
};
If the iterator returns a value of SubscribeError
, it means that a new message
was detected, but it failed to be processed for one of the following reasons:
FailedToDecodeReceiverTicketSignature
- Failed to deserialize the signature
object sent by the sender of this message.
FailedToDecodeDelegatedReceiverAddress
- Failed to deserialize the delegated
receiver object specified by the sender.
Decoding failures tend to occur if the sending/receiving clients are using incompatible versions of the protocol sdk.
Authorized Signer
Whenever the client sends or receives a message, it attempts to invoke its
signMessageFn
to sign the message's corresponding ticket. This sign fn is also
invoked when the client needs to authenticate with a node. It
is common to use the protocol sdk in a browser context with a wallet provider
such as Metamask. Where the signMessageFn
makes a call to the personal_sign
method of the Metamask API. This creates a troublesome user experience, where
every invocation of the sign method also brings up a prompt for the user to
confirm a signature.
If the user has trust in the application using this sdk, and wants to avoid having to confirm the prompt on every signature request, the user can create a delegated authorized account.
This is an account where it's address is stored on chain, and linked to a user's regular account. This linking allows signatures from the delegated account to be used within the protocol, in place of signatures from the user's main account. Creating a authorized account requires sending a transaction to the AuthorizedAccount contract to link the accounts together.
Creating the Authorized Account
// create a new wallet as the authorized account
const senderDelegatedAccount = ethers.Wallet.createRandom();
const authorizedAccounts = AuthorizedAccounts__factory.connect(
PORCINI_CONTRACTS.authorizedAccounts,
senderAccount
);
await authorizedAccounts.authorizeAccount(senderDelegatedAccount.address, [
AuthorizedPermission.SigningTicket,
]);
Applications should securely store the newly generated wallet. WARNING: Leaking this key can allow attackers to spend from the user's escrow without their consent.
Using the Authorized Account
The authorized account can be passed in when creating the sdk client, or calling
the client.setAuthorizedSigner
method.
Client.create({
account,
signMessageFn: wallet.signMessage.bind(wallet),
peerKey,
network: PORCINI_CONTRACTS,
provider,
authenticationOpts: { prefix, suffix },
{
address: decodedOrThrow(Address.decode(senderDelegatedAccount.address)),
sign: (data: Uint8Array | string) => {
return senderDelegatedAccount.signMessage(data);
}
}
});
client.setAuthorizedSigner({
address: decodedOrThrow(Address.decode(senderDelegatedAccount.address)),
sign: (data: Uint8Array | string) => {
return senderDelegatedAccount.signMessage(data);
},
});
When using the client, most functions allow passing in a signatureType
value
to determine which type of signature to use.
import { SignatureType } from "@futureverse/sylo-protocol-sdk/lib/src/utils/utils";
await client.relay.send(receiver, message, SignatureType.Authorized);
The signature type can also be set globally for signing functionality.
client.setSignatureType(SignatureType.Authorized);
Attached Authorized Signer
As an alternative to having the user send a transaction to create a new
authorized account, a attached authorized account
can instead be supplied
alongside each signature. An attached authorized account
does not need to be
persisted onchain, but can be managed entirely locally.
The attached authorized account
has 5 parameters:
account
: The address of the delegated accountexpiry
: A unix timestamp in seconds for when the attached authorized account will expire.prefix
: A string used in constructing the proof messagesuffix
: A string used in constructing the proof messageinfixOne
: A string used in constructing the proof message
The prefix
, suffix
, and infixOne
parameters enable an application to
customize the message that the user will need to sign in order to create the
proof. Creating the proof requires passing in a signing function which creates a
signature signed by the user's wallet.
import { createNewAttachedAuthorizedAccount } from "@futureverse/sylo-protocol-sdk/lib/src/events/utils";
const attachedAuthorizedAccount = await createNewAttachedAuthorizedAccount(
// The signFn can be the metamask personal sign request method for example
signFn,
decodedOrThrow(Address.decode(attachedWallet.address)),
now,
"============PREFIX_START============\nWelcome to the Sylo Notifications Demo App. This personal signing request is used to create a proof for a authorized account. The prefix is configured by the exprience. The next value is the address of the authorized account and must be in the message.\n============PREFIX_END============\n",
"\n============SUFFIX_START============\nThis is the end of the message and is also configured by the experience.\n============SUFFIX_END============",
"\n============INFIX_START============\nThis string sits between the address and the next value, which is the expiry of the authorized account\n============INFIX_END============\n"
);
clientSDK.setAttachedAuthorizedSigner({
attachedAuthorizedAccount,
sign: attachedWallet.signMessage.bind(attachedWallet),
});
The attached authorzied account
can only be used when receiving messages.
Authentication
A client within the network identifies itself with a Peer Key. This key differs from the user's Ethereum account. However, sending and receiving messages within the network is based on the Ethereum account. Thus whenever a user interacts with a Seeker Node via the SDK client, the client needs to authenticate itself, and prove that it is being operated by the user's Ethereum account.
This is achieved by the client sending an authentication request to a node, and the node then returning a challenge which must be signed by the user's Ethereum account.
This authentication mechanism effectively links the user's Ethereum account to the client's peer key. The client will need to authenticate whenever it connects to a new Node that it hasn't authenticated to before.
Scanning
Scanning is the process of connecting to your own, or to a peer's designated Node. Every epoch, a Node is designated to service a particular ethereum account. This designation is based on an algorithm which takes the ethereum address as input, and also compares the Node's SYLO stake for that epoch against other nodes. Where having higher stake, makes it more likely for an account to be designated by that Node.
When messaging another peer, the client will need to scan for that peer's node. This process is automatically managed by the client. Connecting to a new node may also require that the client authenticates itself with that new node.
Errors
Most methods return an Either<Error, Success>
value, and generally attempt to
define all known errors.
Protocol Errors
All communication to a node is done through multiplexed libp2p streams. A
Protocol Error
indicates either a failure to write or read via a libp2p
stream. The possible errors are:
BadDecoding
- Failed to protobuf deserialize a byte value into a known object.
BadInput
- Failed to protobuf serialize a object into bytes. Usually due to a
malformed object.
UnknownType
- All messages exchanged within the network follow a format of
{
type, value;
}
where type
indicates the expected message type. Every stream defines a set of
message types expected to be exchanged within this stream. An UnknownType
indicates receiving an unexpected message type. This may occur if the
incompatible versions between the client or node.
UnexpectedType
- This indicates a known message type for the stream, but it
was received at an unexpected type. Usually indicates incompatible version
between the client ande node.
UnexpectedEOF
- The stream was unexpectedly closed. This usually occurs due to
the Node closing the stream due to an unexpected error. Another possible cause
may be that the underlying connection was dropped due to a network outage.