Infernet
SDK
Architecture

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
  • 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 Coordinators createSubscription() function or off-chain from an authorized signer and EIP-712 message (opens in a new tab) via the createSubscriptionDelegatee() function.

Optionally, Subscriptions can pay for compute responses from nodes and proof verification from verifiers in their choice of paymentAmount of paymentToken.

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 11 parameters:

ParameterDefinition
ownerThe 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.
containerIdThis 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.
frequencyHow 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).
periodIn 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.
redundancyHow 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.
activeAtWhen 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.
lazyWhether to receive a subscription response eagerly or lazily. When set to true (lazily), compute responses are stored in the Inbox, with results delivered by index to the consuming smart contract.
verifierOptional verifier contract (implementing the IVerifier interface) to restrict subscription payments on the basis of proof verification. By default, can be set to address(0) for no proof verification. If a verifier is specified, and proof verification fails, no payment is deducted from a consuming smart contract.
paymentAmountOptional amount to pay in paymentToken each time a subscription is processed. Can be set to 0 for no payment.
paymentTokenOptional payment token. Can be set to address(0) for payment in ETH or any ERC20 token address. If paymentAmount is set to 0, this parameter is ignored as there is no subscription payment.
walletOptional Wallet to use to pay for compute subscription responses. owner must be an approved spender of Wallet. Wallet(s) can be created via the WalletFactory.

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 want MY_DEFI_CONTAINER to be called with inputs exposed via my contracts getContainerInputs() function. I want up to 3 nodes to process this computation, only once, and return eagerly. I don't want any future computation subscription beyond this one request. I don't want to associate any payment or proof verification for this subscription.

// 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,
    lazy: false, // Eager return
    verifier: address(0), // No proof verification
    // No associated payment
    paymentAmount: 0,
    paymentToken: address(0),
    wallet: address(0)
})

Recurring subscription example

My contract is at address(MY_DEFI_CONTRACT). I want MY_DEFI_CONTAINER to be called with dynamic inputs. I have exposed a getContainerInputs() function in my contract. I want a response 31 times, once every day, with up to 2 nodes responding eagerly each time. I don't want to associate any payment or proof verification for this subscription.

// 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,
    lazy: false, // Eager return
    verifier: address(0), // No proof verification
    // No associated payment
    paymentAmount: 0,
    paymentToken: address(0),
    wallet: address(0)
})

One-time lazy subscription w/ 1 ETH payment example

My contract is at address(MY_DEFI_CONTRACT). I want MY_DEFI_CONTAINER to be called with inputs exposed via my contracts getContainerInputs() function. I want up to 3 nodes to process this computation, only once, and return lazily. For each of these responses, I want to incentivize the nodes with a payout of 1 ETH per compute response from address(MY_WALLET) which I have created via the Wallet Factory and funded with 3 ETH. I don't want to restrict this payout on the basis of successful proof verification.

// 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,
    lazy: true, // Lazy return
    verifier: address(0), // No proof verification
    // 1 ETH payment
    paymentAmount: 1 ether,
    paymentToken: address(0),
    wallet: address(MY_WALLET)
})

One-time subscription w/ 1 ETH payment but only on successful proof verification example

My contract is at address(MY_DEFI_CONTRACT). I want MY_DEFI_CONTAINER to be called with inputs exposed via my contracts getContainerInputs() function. I want up to 3 nodes to process this computation, only once, and return lazily. For each of these responses, I want to incentivize the nodes with a payout of 1 ETH per compute response from address(MY_WALLET), which I have created via the Wallet Factory and funded with 3 ETH, but only if their response contains a proof that passes verification from the address(VERIFIER) verifier contract.

// 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,
    lazy: true, // Lazy return
    verifier: address(VERIFIER), // Proof verification via contract implementing IVerifier
    // 1 ETH payment
    paymentAmount: 1 ether,
    paymentToken: address(0),
    wallet: address(MY_WALLET)
})

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 interval 1 when 0 <= t < 10 and 2 when 10 <= t < 20.
  • A subscription that started at time 1500, occuring only once, is at interval 1 when t >= 1500.

To illustrate, here is a subscription with the parameters:

  • frequency = 3, so a maximum of 3 intervals
  • period = 10, so an interval every 10s
  • redundancy = 2, so up to 2 responses per interval

Intervals visualized

⚠️

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

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:

  1. Smart contracts create Subscriptions with the coordinator
  2. 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 each interval
  • Ensuring unique nodes respond each interval
  • Ensuring payments are escrowed and processed
  • Ensuring proof verification occurs successfully
  • 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. We provide an out-of-the-box pattern for this in the Allowlist.

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:

An off-chain observer like an Infernet node is able to track the state of all subscriptions in the system via the Coordinator.

Tracking subscription inputs

All developer smart contracts expose a getContainerInputs() view function that dynamically exposes features based on the subscriptionID, interval, timestamp and caller (fulfilling node). Infernet nodes call this function off-chain when processing a subscription interval for the most up-to-date inputs to consume.

By default, this function is abstracted away from a consumer when inheriting the CallbackConsumer for simplicity.

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 fulfilled
  • deliveryInterval — the subscription interval a response is being delivered for (must be current)
  • input — optional container input bytes
  • output — optional container output bytes
  • proof — optional execution proof bytes
  • nodeWallet — optional node Wallet for payments

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:

  1. If neither parameter exists, the on-chain output field is empty
  2. If one parameter exists, the on-chain output field is the value of that parameter
  3. If both parameters exist, the on-chain output is the encoded concatenation of both rawOutput and processedOutput (akin to abi.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.

Inbox

The Inbox is an optional contract that developers can choose to use to lazily store and retrieve timestamp-ordered compute responses from containers in the Infernet system. Two of its applications include:

Lazy response consumption

By default, when creating a subscription with the lazy parameter set to false, a response callback to a consuming contract's rawReceiveCompute function contains all input, output, and proof bytes. Jog your memory by referencing the function signature below:

function rawReceiveCompute(
    uint32 subscriptionId,
    uint32 interval,
    uint16 redundancy,
    address node,
    // In an eager system (with `lazy == false`), these parameters contain data
    bytes calldata input,
    bytes calldata output,
    bytes calldata proof,
    // But, these parameters are empty
    bytes32 containerId,
    uint256 index
) external {}

In eager systems, this approach works well but leaves responsibility for storage of these compute outputs to a downstream consumer.

With the v1.0.0 release of the Infernet SDK, we introduced a new way to store and consume these outputs lazily by specifying lazy as true during subscription creation. With this parameter toggled, rather than responses returning input, output, and proof bytes, they instead store this data to an append-only Inbox contract, returning a reference containerId and index that consumers can use to read from the Inbox instead.

ℹ️

This approach of lazily storing and consuming data is especially useful for applications that don't need to immediately consume responses. For example, an on-chain smart contract that aggregates over compute outputs, may choose to store responses lazily and retrieve them in the future in bulk. Alternatively, another contract may only choose to retrieve outputs in the case of dispute from a user.

Optimistic writes

The second application of the Inbox is for optimistic writes.

Today, consumers in the Infernet system are only able to consume compute responses for Subscriptions that they have created and requested. Yet, a common on-chain mechanism (especially used amongst oracle systems) is to optimistically publish responses on-chain that any consumer can read, without having previously requested them.

The Inbox exposes a function write() which enables exactly this use-case, allowing any node operator to optimistically submit responses for a container without a consumer requesting a response.

️🦹

Because anyone can publish to the Inbox via the write() function, it is especially important when consuming outputs from the Inbox to validate the responding node and a (subscriptionId, interval)-pair if retrieving a response for a lazy subscription.

Nodes that provide compute responses without associated subscriptions will find their InboxItem-entries have nullified subscriptionId and interval(s). This makes it easy for downstream consumers to safely verify the outputs they consume.

Payments

With the release of Infernet v1.0.0, the ability to pay for Subscription(s) was introduced. At its core, payments functionality is made up of a few components:

  1. Wallet smart contracts used by nodes, consumers, and verifiers (created via WalletFactory)
  2. IVerifier-implementing optional proof verification contracts (new participant)
  3. A simple protocol Fee registry (with fees currently set to 0%)
  4. Slight modifications to a Subscription object to allow specifying payments

Through these components, consumers can now choose to pay for each Subscription compute response they receive, optionally restricting payment only to nodes that respond with valid proofs of execution.

Wallet

Every

  1. Consumer that wants to pay for a subscription
  2. Node that wants to be paid for their compute
  3. Verifier that wants to be paid for proof verification

must have a Wallet. Wallet(s) are simple smart contracts that hold some ETH or ERC20 balance, and have a single owner. They can be created by calling createWallet() on the WalletFactory.

At any point of time:

  1. A wallet can have some available balance (that an owner can withdraw)
  2. Some escrowed balance (that is temporarily locked in payment escrow, for up to 1-week max)
  3. Delegate one (or multiple) consumer contract(s) to spend its balance (akin to ERC20 allowances)

By keeping Wallet(s) seperate from consumer contracts, the Infernet system ensures safety through isolation. Only an owner and the Infernet coordinator itself can manage wallet balances.

For consumers

For consumers that wish to use their Wallet balance to pay for a subscription, the process is straightforward:

  1. First, a wallet owner must approve() the consumer address to spend some token amount from its balance
  2. Next, during subscription creation (via CallbackConsumer or SubscriptionConsumer), a consumer needs to simply populate the paymentAmount, paymentToken, and wallet parameters

For node operators

For node operators that want to be paid for their compute responses, you must simply supply a nodeWallet (see payment_address) when submitting responses via deliverCompute() or deliverComputeDelegatee().

By default, the Infernet node handles payments for you.

For proof verifiers

For proof verification smart contracts implementing the IVerifier interface, a valid Wallet address must be returned from the getWallet() function.

End-to-end flows

Payments functionality enables three general flows for consumers, nodes, and verifiers alike.

Payment without proof verification

  1. A Wallet owner approves address(consumer) to spend up to paymentAmount paymentToken
  2. The consumer contract creates a new Subscription that offers to pay paymentAmount paymentToken from the aforementioned Wallet for each subscription response
  3. The Subscription does not specify any verifier
  4. Every time a node responds to the Subscription, it is instantly paid paymentAmount paymentToken from the consumers Wallet, minus some protocol fee

Payment with atomic proof verification

  1. A Wallet owner approves address(consumer) to spend up to paymentAmount paymentToken
  2. The consumer contract creates a new Subscription that offers to pay paymentAmount paymentToken from the aforementioned Wallet for each subscription response
  3. The Subscription explicitly specifies a verifier contract that must validate the returned proof before payment is released. This verifier contract can optionally charge some fee for its verification service
  4. Every time a node responds to the Subscription, it puts up paymentAmount paymentToken in slashable funds should its proof verification fail
  5. Upon submission of a subscription response, the verifier contract is atomically called to verify the proof. If valid (true), the node is instantly paid paymentAmount paymentToken from the consumers Wallet, minus some protocol fee and verifier fee. If invalid (false), the node's Wallet is slashed paymentAmount paymentToken, and the consumer is instantly paid paymentAmount paymentToken, minus some protocol fee and verifier fee

Payment with optimistic proof verification

  1. A Wallet owner approves address(consumer) to spend up to paymentAmount paymentToken
  2. The consumer contract creates a new Subscription that offers to pay paymentAmount paymentToken from the aforementioned Wallet for each subscription response
  3. The Subscription explicitly specifies a verifier contract that must validate the returned proof before payment is released. This verifier contract can optionally charge some fee for its verification service
  4. This verifier may take up to 7 days (maximum enforced by Infernet) to verify a proof (imagine this being a multisig wallet or an UMA dispute oracle)
  5. Every time a node responds to the Subscription, it puts up paymentAmount paymentToken in slashable funds should its proof verification fail. These funds are escrowed (simply locked in the respective Wallet via the Coordinator) for the duration of the proof verification.
  6. Upon submission of a subscription response, the verifier contract is called to verify the proof
  7. The verifier then has 7 days to attest to the validity of the proof. If valid (true), the node is instantly paid paymentAmount paymentToken from the consumers Wallet, minus some protocol fee and verifier fee. If invalid (false), the node's Wallet is slashed paymentAmount paymentToken, and the consumer is instantly paid paymentAmount paymentToken, minus some protocol fee and verifier fee
  8. Should the verifier fail to attest within 7 days, any party can finalize the system in favor of the node
ℹ️

Keen observers will notice that, behind-the-scenes, atomic and optimistic proof verification are implemented in the same way. The key difference being that in the atomic case, funds are escrowed and settled within the same transaction, whereas in the optimistic case, settlement can take up to 7 days.

Capital-constrained nodes who are confident in their proof validity may choose to exploit Flash Loans (opens in a new tab) to fund their atomic escrow capital requirements.