This document explains some of the main design decisions behind Interledger.rs and how the components fit together.
Note that this document assumes some familiarity with the Interledger Protocol (ILP). If you want to read more about the protocol or familiarize yourself with the packet format and flow, please see the Interledger Architecture specification.
Key Design Features:
- Every ILP packet is processed by a chain of stateless
Services
- All state is kept in an underlying database or
Store
- All details related to an account or peer are bundled in an
Account
object, which is loaded from theStore
and passed through theServices
- Nothing is instantiated for each packet or for each account; services that behave differently depending on account-specific details or configuration use methods on the
Account
object to get those details and behave accordingly - Multiple identical nodes / connectors can be run and pointed at the same underlying database to horizontally scale a deployment for increased throughput
Interledger.rs is designed around a pattern called services, which is an abstraction used to define clients and servers for request / response protocols. Each service accepts a request and asynchronously returns a successful response or error. Services can be chained together to bundle different types of functionality, add handlers for specific requests, or modify requests before passing them to the next service in the chain. This design is inspired by Tower.
Most components in Interledger.rs define either IncomingService
s or OutgoingService
. An IncomingService
accepts an ILP Prepare packet and a "from" Account
(representing which account or peer the packet came from) and returns a Future that resolves to either an ILP Fulfill packet or an ILP Reject packet. An OutgoingService
is very similar but the request also contains a "to" Account
that specifies which account or peer the Prepare packet should be sent to.
Most services implement either the IncomingService
or OutgoingService
traits and accept a "next" service that implements that same trait. The service will execute its functionality and then may call the next service to pass the request through the chain. A service that handles a given request, such as the IldcpService
may return a Fulfill or Reject without calling the next service.
The Router
service is unique because it implements the IncomingService
trait but accepts an OutgoingService
as the next service. It uses the routing table returned by the Store and the destination ILP Address from the Prepare packet to determine the "to" Account
the request should be forwarded to. The "to" account may be another intermediary node or the final recipient.
Interledger.rs services operate on deserialized ILP Prepare, Fulfill, and Reject packets. However, this implementation uses zero-copy parsing and the "deserialized" ILP packet object contains the serialized packet buffer and simple pointers to the location of each field of the packet. Querying the fields of the packet gives immutable references to the underlying buffer. Additionally, this library enables the two mutable fields in an ILP Prepare packet (amount
and expires_at
) to be changed in-place so that even a forwarding node / connector does not need to copy the packet.
This approach is more convenient than simply passing a serialized buffer between components (because each one needs access to the deserialized data) and more efficient than copying the fields of the packet into an object only to reserialize them later.
Interledger.rs combines details and configuration related to the customers, peers, and upstream providers of a node into Account
records.
Each service can define traits that the Account
type passed to it in the IncomingRequest
s or OutgoingRequest
s it handles must implement. For example, the HttpClientService
requires Accounts
to provide get_http_url
and get_http_auth_header
methods so that the HTTP client knows where to send the outgoing request. A specific Account
type can implement any or all of these service-specific traits.
Account
details are generally loaded from the Store
and passed through the chain of services along with the Prepare packet as part of the request. This gives each service access to the static account-related properties and configuration it needs to apply its functionality without hitting the underlying database for each one. Another nice benefit of this design is that Rust compiler will not allow a Store
to be passed to a particular service unless the Store
's associated Account
type implements the required methods.
The service pattern enables all of the components to be used separately as microservices and bundled together into an all-in-one node. To make a standalone microservice, a given service, such as the StreamReceiverService
, can be attached to an HttpServerService
so that it will respond to individual Prepare packets forwarded to it via HTTP. Alternatively, the same StreamReceiverService
can be chained together with other services so that it will handle Prepare packets meant for it and pass along any packets that are meant to be forwarded.
Some bundles of specific functions are available through the CLI. If there are other bundles that you would find useful, please feel free to submit a Pull Request to add them!
The Store
is the abstraction used for different databases, which can range from in-memory, to Redis, to SQL, NoSQL, or others.
Each service that needs access to the database can define traits that the Store
type it is passed must implement. For example, the HttpServerService
requires the Store
to implement a get_account_from_http_auth
method that it will use to look up the Account
details for an IncomingRequest
based on the authentication details used for the incoming HTTP request. The Router
requires the Store
to provide routing_table
and get_accounts
methods that it uses to determine the next Account
to send the request to and load the Account
details from the database.
Store
types can implement any or all of the service-specific Store
traits. The Rust compiler ensures that a Store
passed to a given service implements the trait(s) and methods that that service requires.
This approach to abstracting over different databases allows Interledger.rs to be used on top of multiple types of databases while leveraging the specific capabilities of each one. Instead of having one database abstraction that is the least common denominator of what each service requires, such as a simple key-value store, each service defines exactly what it needs and it is up to the Store
implementation to provide it. For example, a Store
implementation on top of a database that provides atomic transactions can use those for balance updates without the balance service needing to implement its own locking mechanism on top.
A further benefit of this approach is that each Store
implementation can decide how to save and format the data in the underlying database and those choices are completely abstracted away from the services. The Store
can organize all account-related details into sensible tables and indexes and multiple services can access the same underlying data using the service-specific methods from the traits the Store
implements.