Menu

Connect an ILP client over BTP

Interledger software such as ilp-plugin-btp and ilp-protocol-stream can use a Dassie node as its parent. This page gets your client an API key, connects it, and shows what the node lets it do.

On this page
  1. Make an API key
  2. Connect
  3. The address ILDCP gives you
  4. Pay with STREAM
  5. What your client may do
  6. When it does not connect

Make an API key

The owner makes keys in the node's wallet.

  1. Open Settings, and find the API keys card.

  2. Under Create BTP token, give the key a label, so that you know it again, up to 64 bytes.

  3. Pick its Ledger. The client pays and is paid in that ledger's asset, and its packets never change ledger. On the testnet, pick the ledger of a settlement hub, such as hub-xrpl-testnet-xrp+xrp: nodes pay each other over hubs, so only a hub's ledger reaches other nodes.

  4. Set a Spending cap: an amount per hour, per day or per week. Leave it empty for no limit.

  5. Press Create BTP token, and copy the URL. The wallet shows the token once: the node keeps only its hash.

The URL looks like this:

btp+wss://:<token>@node.example.com/btp

On a node whose HTTPS port is not 443, the port follows the host. A node without a hostname, such as the desktop app, names localhost and its port, for a client on the same computer. BTP is served only on the node's own HTTPS port: a front door never passes it on.

A node keeps up to 64 keys. The owner can change a key's cap, or revoke it, on the same card at any time. API keys shows the owner's side.

Connect

Install the client libraries:

$npm install ilp-plugin-btp ilp-protocol-ildcp ilp-protocol-stream ws

Pass the URL in an environment variable, so that it stays out of your code, and connect:

client.mjs
import BtpPlugin from "ilp-plugin-btp";
import ILDCP from "ilp-protocol-ildcp";

const plugin = new BtpPlugin({ server: process.env.BTP_URL });
await plugin.connect();

const { clientAddress, assetCode, assetScale } = await ILDCP.fetch((data) =>
  plugin.sendData(data)
);
console.log(clientAddress, assetCode, assetScale);
$BTP_URL='btp+wss://:<token>@node.example.com/btp' node client.mjs
test.das.hauavng4okp6d55tghlsiqvnn6j5v6mpmktgnfq.k3x9qa XRP 9

The plugin sends the token in BTP's auth message. The node expects it within ten seconds of the WebSocket opening.

A node on its own certificate

A node without a hostname, such as the desktop app, serves a certificate it made itself, for localhost. A server node serves one too, for its domain name, until Let's Encrypt has given it one (Certificates). No certificate authority vouches for it, and the plugin checks certificates, so the snippet above fails the TLS handshake. Fetch the node's certificate once, with the host and port of the BTP URL, over a network you trust; the port is 443 when the URL names none. For the desktop app the host is localhost:

$openssl s_client -connect <host>:<port> -servername <host> </dev/null | openssl x509 > node.pem

Then hand the plugin a WebSocket that trusts exactly that certificate. It comes from the ws package, which the plugin uses too:

client.mjs
import { readFileSync } from "node:fs";
import BtpPlugin from "ilp-plugin-btp";
import WebSocket from "ws";

const ca = readFileSync("node.pem", "utf8");
class TrustingWebSocket extends WebSocket {
  constructor(url) {
    super(url, { ca });
  }
}

const plugin = new BtpPlugin(
  { server: process.env.BTP_URL },
  { WebSocket: TrustingWebSocket },
);
await plugin.connect();

The node keeps that certificate across restarts. It makes a new one only when it nears its end or when the node's names change; fetch it again then.

The address ILDCP gives you

ILDCP answers with an address below the node's own, test.das.<node ID> on the testnet, with a segment of six random letters and digits for this connection. Each connection gets a new one, so do not hand it out as a lasting address. Packets sent to it reach your client while it is connected; when it goes away, the packets on their way to it fail.

A node without a hostname, such as the desktop app, is a periphery node: it has no route of its own, and other nodes reach it beneath one of its uplinks. So they reach your client at <uplink's address>.<node ID>.<segment>, not at the address ILDCP gives. Sending works the same either way.

The asset is the key's ledger's: its code, and a scale of 9. XRP and the testnet's dollar both count in billionths.

Why it works this way: why each endpoint gets its own ledger's asset.

Pay with STREAM

With the plugin connected, any STREAM sender can pay. Here it pays an incoming payment's credentials, the ilpAddress and sharedSecret from its methods (how to get them):

import { createConnection } from "ilp-protocol-stream";

const connection = await createConnection({
  plugin,
  destinationAccount: method.ilpAddress,
  sharedSecret: Buffer.from(method.sharedSecret, "base64url"),
});
const stream = connection.createStream();
await stream.sendTotal(500_000_000);
await connection.end();
await plugin.disconnect();

createConnection asks ILDCP for your address itself. Disconnect the plugin when you are done: otherwise it reconnects on its own. STREAM on Dassie says more about the STREAM side.

What your client may do

  • Money moves only in ILP packets. A BTP Transfer is refused with an error that says so.
  • It spends from the owner's funds on the key's ledger. Each key has an account of its own under them, which only records what went through it. A packet the owner's funds cannot cover gets T04.
  • Its packets never change ledger. The node sends them only to next hops it shares the key's ledger with. A destination with no such route gets F02.
  • No packet may carry more than the node's largest packet, 0.1 of a unit, such as 0.1 XRP. A larger one gets F08, with the most that would pass, and STREAM splits what it sends to fit.

The spending cap

A cap allows at most an amount per window, and refills continuously, not all at once when the window ends. Over any stretch of time, a key spends at most the amount, plus the amount again for each window that passes.

  • Only money that leaves the owner's funds counts. Paying the node's own wallet, or another key's client on the same node, does not.
  • Every connection made with one key shares its cap, and packets in flight count as if they arrived.
  • A packet the cap has no room for now gets T04, which STREAM waits out and tries again. A packet larger than the whole cap gets F08.

Error codes says what to do about each code.

Why it works this way

When it does not connect

What you see Why What to do
The WebSocket closes with authentication failed The token is wrong, or the owner revoked it. Ask the owner for a new key.
HTTP 429, Too many failed attempts Your address failed to log in too often. Only failures count, so a client may reconnect as often as it needs. Wait a while, and use the right token.
The WebSocket closes with the token's ledger does not run The owner turned off the scheme of the key's ledger. The key works again once it runs. Ask the owner.
HTTP 503, Too many BTP clients The node holds as many BTP connections as it takes. Try again later.
HTTP 503, The ledger is not running The node is not set up, or is starting. Try again in a moment.
The WebSocket closes with no authentication The client sent no token within ten seconds. Check that the URL has the token before the @.

When the owner revokes a key, its connections close, and from that moment they take no new packets.

Why it works this way

A kopje of granite blocks against the sun, in veld grass; one dassie climbs its slab, one keeps lookout on top inside the sun, and one sleeps on the next rock; on a wide screen, a pair sits on a kopje to the left