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
- All state is kept in an underlying database or
- All details related to an account or peer are bundled in an
Accountobject, which is loaded from the
Storeand passed through the
- 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
Accountobject 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
Services - Core Internal Abstraction
What are Services?
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 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
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.
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.
Zero-Copy ILP Packet Forwarding
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 (
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
Each service can define traits that the
Account type passed to it in the
OutgoingRequests it handles must implement. For example, the
Accounts to provide
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
Account type implements the required methods.
Monolith or Microservices
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!
Stores - Database Abstraction
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
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