Architecture
The Infernet SDK is composed of two core sets of contracts.
At it's heart, lies the Coordinator. This contract:
- Is responsible for managing Subscriptions, the core unit of the Infernet SDK
- Manages the node lifecycle (via
Manager.sol
), allowing nodes to register and activate in the network - Is what Infernet nodes listen to for details about new or cancelled requests for compute
- Is the authorized middle-man proxy between Infernet nodes and subscribing user contracts
Developers inherit from a set of Consumers
in their smart contracts, namely CallbackConsumer and SubscriptionConsumer. These contracts:
- Expose simple functions to create different types of Subscriptions at the Coordinator
- Allow receiving subscription responses via an authorized callback function only from the Coordinator
Notice that your contracts inherit from Consumers
that explicitly set the
address to a Coordinator. Coordinator's are responsible for enforcing
subscription correctness, ensuring things like: unique nodes only respond once
per interval, or that nodes adhere to your subscription settings, before any
responses reach your smart contracts.
Subscriptions
Subscriptions are the core units of the Infernet SDK.
What is a subscription?
A subscription is a request (one-time or recurring) made by a user to an Infernet node to process some compute. Users initiate subscriptions; nodes fulfill these subscriptions.
A subscription is referenced by its ID
, a monotonically-increasing, unique identifier.
Subscriptions can be created on-chain via the Coordinator
s createSubscription()
function or off-chain from an authorized signer and EIP-712 message (opens in a new tab) via the createSubscriptionDelegatee()
function.
For most developers, it is unnecessary to manipulate raw subscriptions, since we expose simple consumer interfaces (CallbackConsumer, SubscriptionConsumer) that developers can inherit to handle the bulk of background logic. Still, it is useful to understand how a subscription works.
Subscriptions in definition
In definition, a subscription is a struct containing nine parameters:
Parameter | Definition |
---|---|
owner | The on-chain address that owns a subscription. This is usually your applications' smart contract. By default, it is initialized to the address that creates the subscription. This is (1) the address that receives any subscription outputs, and (2) the only address that can cancel a subscription. |
containerId | This is the unique identifier of the compute container(s) you want an Infernet node to run. Your subscriptions' inputs are passed to these containerId container(s) and any output is directly returned. You can specify more than one container to run by delimiting with a comma (, )—for example: container1-id,container2-id,container3-id . |
inputs | There are two ways to expose input data or features for an Infernet node to consume. You can either encode the data in this parameter or leave this empty and expose a getContainerInputs() function in your consumer contract. |
frequency | How many times is a subscription processed? If set to 1 , a subscription is processed once . If set to 2 , a subscription is processed twice . If set to UINT256_MAX , a subscription is processed indefinitely (so long as other conditions hold true). |
period | In seconds, how often is a subscription processed? If set to 60 , I want a response every 60 seconds , frequency times. Can be set to 0 if a subscription is processed once. |
redundancy | How many unique nodes do I want responses from in each period time? If set to 1 , owner receives up to 1 successful response each period . If set to 5 , owner receives up to 5 successful responses, each period . |
activeAt | When does the subscription allow receiving its first response? When period > 0 , this is default set to currentTimestamp + period . As in, the first response is received period from creation. When period = 0 , this is set to currentTimestamp , allowing immediate response. |
maxGasPrice | What is the max gas price of a response transaction that a node should send for your subscription? |
maxGasLimit | What is the max gas limit of a response transaction that a node should send for your subscription? Generally, this should equal the amount of gas it takes for your contracts callback function processing + DELIVERY_OVERHEAD_WEI (a fixed surcharge for the delivery operation). |
Example subscriptions
It is useful to illustrate some example subscriptions as they would appear as raw structs:
One-time subscription example
My contract is at
address(MY_DEFI_CONTRACT)
. I wantMY_DEFI_CONTAINER
to be called withMY_DEFI_INPUTS
. I want up to3
nodes to process this computation, onlyonce
, and return immediately. I don't want any future computation subscription beyond this one request. The computation I do in my contract when an output is received costs200_000
gas and I don't want nodes to respond if the gas price is greater than100
gwei.
// Pseudocode
Subscription({
owner: address(MY_DEFI_CONTRACT), // Recipient + subscription owner
frequency: 1, // Processing only once
period: 0, // Not recurring
redundancy: 3, // 3 responses for my once frequency
activeAt: now(), // Immediately active
containerId: "MY_DEFI_CONTAINER",
inputs: MY_DEFI_INPUTS,
maxGasPrice: 100e9, // 100 gwei
maxGasLimit: 200_000 + DELIVERY_OVERHEAD_WEI, // my gas overhead + response overhead
})
Recurring subscription example
My contract is at
address(MY_DEFI_CONTRACT)
. I wantMY_DEFI_CONTAINER
to be called with dynamic inputs. I have exposed agetContainerFeatures()
function in my contract. I want a response31
times, once everyday
, with up to2
nodes responding each time. The computation I do in my contract when an output is received costs100_000
gas and I don't want nodes to respond if the gas price is greater than20
gwei.
// Pseudocode
Subscription({
owner: address(MY_DEFI_CONTRACT), // Recipient + subscription owner
frequency: 31, // Processing 31 times
period: 1 days, // Recurring every day
redundancy: 2, // 2 responses for each daily frequency
activeAt: now() + 1 days, // Active at the next period
containerId: "MY_DEFI_CONTAINER",
inputs: "", // Using dynamic getContainerFeatures() function exposed in MY_DEFI_CONTRACT
maxGasPrice: 20e9, // 20 gwei
maxGasLimit: 100_000 + DELIVERY_OVERHEAD_WEI, // my gas overhead + response overhead
})
Delivery intervals
All fulfilled computation requests are uniquely identified by a combination of three parameters:
subscriptionId
interval
respondingNodeAddress
While the subscriptionId
and respondingNodeAddress
are self-explanatory, interval
is not.
An interval
is the current cycle of a subscription. As in, segmenting elapsed time since the start of a subscription (remember, activeAt
) by the period
of a subscription. For example, where t
is current time:
- A subscription that started at time
0
, repeating indefinitely every 10 seconds is at interval1
when0 <= t < 10
and2
when10 <= t < 20
. - A subscription that started at time
1500
, occuring only once, is at interval1
whent >= 1500
.
To illustrate, here is a subscription with the parameters:
frequency = 3
, so a maximum of3
intervalsperiod = 10
, so an interval every10s
redundancy = 2
, so up to2
responses per interval
Make note of the fact that the second interval only has 1
node response,
even when redundancy = 2
. Redundancy is simply an upper bound, and when
conditions don't hold (say, blockchain gas fees surpass the maxGasPrice
a
consumer is willing to pay), intervals remain empty. This also limits stale
responses.
Managing subscriptions
Subscriptions are managed at the Coordinator. Developers rarely have to access these raw functions and should instead opt to use one of the provided Consumer
contracts. Still, for reference:
Coordinator.sol
:- Exposes
createSubscription()
that allows creating new subscriptions - Exposes
cancelSubscription()
that allows cancelling a created subscription - Exposes
getSubscriptionInterval()
to easily calculate the current interval for a subscription - Exposes the current highest subscription ID via
id()
- Exposes all subscriptions via an ID to subscription mapping:
subscriptions()
- Exposes
deliverCompute()
to let Infernet nodes fulfill subscriptions
- Exposes
EIP712Coordinator.sol
:- Exposes an off-chain delegated subscription creation function:
createSubscriptionDelegatee()
- Exposes
deliverComputeDelegatee()
to let Infernet nodes atomically create and fulfill a signed, off-chain delegate subscription
- Exposes an off-chain delegated subscription creation function:
Coordinator
The Coordinator is the coordination layer between developer smart contracts and off-chain Infernet nodes.
Coordinator and my contract
Developer smart contracts interface with the Coordinator in two ways:
- Smart contracts create Subscriptions with the coordinator
- Smart contracts accept inbound subscription fulfillments from nodes, via the coordinator
Behind the scenes, the Coordinator performs an extensive set of checks and safety measures before delivering responses to developer smart contracts, including:
- Ensuring subscription responses are sent to the right
owner
- Ensuring only active subscriptions are fulfilled (remember,
activeAt
) - Ensuring current, not stale subscriptions are fulfilled (via
period
,interval
) - Ensuring only up to
frequency
responses are sent eachinterval
- Ensuring unique nodes respond each
interval
- Ensuring responses consume under or equal to
maxGasLimit
gas - Ensuring responses are submitted at under or equal to
maxGasPrice
price - Ensuring responses execute successfully
A Coordinator
is the last checkpoint and intermediary between the outside
world and your contracts'
rawReceiveCompute
callback function. While the default coordinator performs an extensive set of
checks and safety measures, at some point, there may exist alternative
coordinator implementations that offer their own unique set of features. It is
important to be cautious and audit your coordinator implementation.
Notice that while a Coordinator
enforces that unique nodes respond to a
subscription each interval
, it makes no guarantees about which nodes
respond. If you are performing compute using a private containerId
or
accepting optimistic responses (outputs without an on-chain,
succinctly-verifiable proof
), you may choose to restrict the set of Infernet
nodes that can respond to your subscriptions in your own smart contract,
permissioning the
_receiveCompute()
function.
Coordinator and Infernet nodes
Infernet nodes track state of current subscriptions and deliver subscription output via the Coordinator.
Tracking subscription state
By default, the coordinator exposes view functions like subscriptions()
to access the current state of any subscription by its subscription ID. In addition, the Coordinator emits events when:
SubscriptionCreated
— a subscription is createdSubscriptionCancelled
— a subscription is cancelledSubscriptionFulfilled
— a subscription receives a response
Through syncing to a snapshot block and continuously replaying these events, an off-chain observer like an Infernet node is able to track the state of all subscriptions in the system.
Tracking subscription inputs
Subscription inputs can be exposed to off-chain Infernet nodes in one of two ways:
- At subscription creation, users can specify arbitrary bytes in the subscription's
inputs
parameter. These can only be set once (thus, commonly used by theCallbackConsumer
) and are read by nodes at the same time they collect subscription state. - Developer smart contracts can expose a
getContainerInputs()
view function that dynamically exposes features based on thesubscriptionID
,interval
,timestamp
andcaller
(fulfilling node). Infernet nodes call this function off-chain when processing a subscription interval for the most up-to-date inputs to consume.
Fulfilling subscriptions
When ready, Infernet nodes perform off-chain computation and fulfill subscription responses at the Coordinator
via functions like deliverCompute()
or deliverComputeDelegatee()
, specifying:
subscriptionId
— the ID of the subscription being fulfilleddeliveryInterval
— the subscription interval a response is being delivered for (must be current)input
— optional container input bytesoutput
— optional container output bytesproof
— optional execution proof bytes
At the Ritual ML Workflow container layer, developers can return encoded bytes corresponding to rawInput
, processedInput
, rawOutput
, procesedOutput
, and proof
fields. The Infernet Node simply consumes and packs these parameters into the on-chain input
, output
, and proof
fields.
By default, the Infernet SDK enforces no set structure or encoding for
input
, output
, or proof
, instead resorting to a dynamic and arbitrary
bytes calldata
type. This affords developers maximum flexibility to use the
Ritual ML Workflows to construct the
appropriate on-chain response for their contract. For example, for optimistic
computation where a succinct proof
cannot be generated, the data field can
instead be used to transport arbitrary encoded request metadata.
Generally, container outputs are published on-chain in one of three ways. Taking the rawOutput
and processedOutput
parameters as an example:
- If neither parameter exists, the on-chain
output
field is empty - If one parameter exists, the on-chain
output
field is the value of that parameter - If both parameters exist, the on-chain
output
is the encoded concatenation of bothrawOutput
andprocessedOutput
(akin toabi.encode(rawOutput, processedOutput)
)
On Layer-2 networks that derive their security from Ethereum mainnet, the bulk of a transactions cost is its L1 data fee (opens in a new tab), the cost to publish its transaction data to Ethereum. On L2 networks, when using callback-based systems like Infernet that transport new data to the chain via calldata
, you should keep in mind the structure of your data and ways to optimize (opens in a new tab) what is posted on-chain. Choose to perform complex, yet cheap, computation on-chain rather than publish simple but sparse data via calldata
.
As a part of nodes delivering outputs to the Coordinator
, developer smart
contracts'
rawReceiveCompute
functions are called. These functions perform arbitrary execution (for
example, in cases where a succinct proof
is applicable, proof validation
would occur within this function). For this reason, it is crucial for Infernet
nodes to simulate transactions before they are broadcasted on-chain, to
prevent transaction failure due to developers' callback functions failing.