Read a node's snapshot
A core node says what it swaps at in a snapshot: one small datagram, signed with its node key. Its peers get each new one over their peering. Anyone else can ask for it over UDP. This page shows how to ask, how to check what comes back, and how to read the prices in it.
On this page
What a snapshot says
A snapshot lists the settlement methods the node's peers can pay it on, such as XRP in the testnet's XRP hub. For each one it gives:
-
A tag: four bytes of a hash of the method's scheme
ID, such as
hub-xrpl-testnet-xrp. You know the schemes you run, so you compute their tags and look them up. - A bid: the least the node gives, in its own accounting unit, for one unit of the method's ledger that comes in.
- An ask: the most the node asks, in that same unit, for one unit that goes out.
- A ladder for each: how much can come in, or go out, before the price falls by each drop of a grid. The grid, eight drops in basis points, is in the snapshot.
A swap from one method to another is priced as the first one's bid over the second one's ask, walked down both ladders as it grows. The node promises that much from the state it was in when it made the snapshot: at that moment, a swap would get at least what the snapshot says. The fee and the spread are already in the quotes: a node folds them into its bid. A quote of zero means the node does not convert that side right now, for want of a fresh exchange rate, say, or because it holds none of that asset.
The node makes a new snapshot at most once a second while its prices
move, and at least every 30 seconds otherwise. Each one counts for at
most 90 seconds, less when an exchange rate it uses gets old sooner;
its validUntil says when it ends. Each carries a
sequence, so you can tell a newer one from an older one.
Only a core node, one with a domain name, has a snapshot, and only while its owner offers swaps to its peers. That is on by default, in the wallet's settings under Peers, as Swaps for peers. How a node sets its prices is on Exchange rates, and every byte of the format is on Snapshot wire format.
Why snapshots of quotes and ladders: a few bytes per method, whatever the number of pairs, and they say how far a price holds, not only where it starts.
Who reads them
Peers read each other's snapshots to swap assets with each other. A node subscribes to its peers' snapshots over the peer protocol and checks each one. A core node swaps by itself when a peer's prices beat its own costs and move its holdings toward balance. Peers never ask on the snapshot's UDP port.
dassie market-maker works the same way. It reads the
snapshots its own node took from its peers, through the owner's
ListPeerSnapshots call, and trades by asking its node to
swap with a peer. See
Run a market maker. If you
run a node, that call gives your own code its peers' snapshots
already checked and decoded, for the first 64 peers by node ID. It
has them only while Swapping with peers is on in the
wallet's settings under Peers (on by default): only then does the
node subscribe.
Asking over UDP is for everyone else: a tool that compares nodes' prices, or a market maker choosing whom to peer with.
Finding a node
Fetch the node's discovery document,
https://<host>/.well-known/dassie/node. It names
the node's id, its two public keys,
publicKey and slhDsaPublicKey in base64url,
and the UDP port it answers on, feedPort:
$curl https://boot1.dassie.xyz/.well-known/dassie/node
{"id":"32f6pclvm5p5xxdeqigvgxpjezayxacvmnnwgcy","publicKey":"2DJ7XMcmzgUu-Ec50PSdPvNHL5_4PKo6WmYrP4nMHEU","slhDsaPublicKey":"4nvvGnn31qeJEYBdzzulzaDcIpFtdFq9HeNhA6fJNeg","feedPort":7768}
Check that the ID is the hash of the two keys before you trust
either; the reader below shows how.
feedPort is there only while the node answers: it has a
snapshot that counts now, and the socket to answer on. Programs that
read the network's link states find the port there too, in the entry
of each core node that offers swaps.
Asking for it
Send one request to that port on the node's host. A request is 1180
bytes: version 02, tag 80, then zeros. The
node answers with its latest snapshot, once, from the port you sent
to. That is all: there is no subscription and no cookie, and the node
keeps nothing about you.
- Why so long: the request is as long as the largest snapshot, so the node never sends anyone more bytes than it got. Anything else, a request one byte short or with padding that is not zero, gets no answer.
- Ask again for news. Each answer is the snapshot of the moment. To follow a node, ask again from time to time: it makes a new snapshot at least every 30 seconds.
- Retry when nothing comes. UDP may lose a datagram either way. The node also answers at most 64 requests a second, from everyone together, so a busy node may skip yours.
Checking a snapshot
A snapshot is 02 81, then its sequence and its
validUntil, both milliseconds since 1970, the grid, the
number of methods in two bytes, 01 and the count, the
methods, and a 64-byte Ed25519 signature. Take a snapshot only if all
of these hold:
-
It decodes. The version byte is
02, every value has its one canonical encoding, the rules below hold, and nothing follows the signature. Refuse anything else. -
Its rules hold.
validUntilis after the sequence; the grid's drops rise and stay below 10000 basis points; there are at most 36 methods, in ascending order of their tags, each once; and a side with a quote of zero has a ladder of zeros. -
It is this node's. The signature verifies under
the
publicKeyof the node you asked, over the snapshot's object identifier,0d 2b 06 01 04 01 83 a8 78 05 8a 77 03 02, followed by every byte before the signature. The identifier is not sent, and neither is the key: you know both. Dassie verifies by the ZIP-215 rules and refuses keys of small order; a key a node derives from its phrase is never one. - It is newer. Its sequence is larger than that of the last snapshot you took from this node, so a late or replayed datagram never sets prices back.
-
It still counts. Now is before its
validUntil. With no snapshot that counts, assume nothing about the node's prices.
A snapshot is signed, not encrypted. It says nothing a node's packets would not.
Reading the prices
Every number in a snapshot is defined in integers, so every reader gets the same values:
-
A quote is 32 bits: an exponent
ein the top 8 and a mantissamin the low 24, worthm × 2e − 150. The mantissa's top bit is set, unless the whole quote is zero. -
A ladder is 9 bytes for 8 amounts, in units of the
method's ledger. The first amount is 16 bits: an exponent in the top
6 and a mantissa in the low 10, worth
m × 2e. Each later amount is one byte, a stepbup from the amount before:floor(previous × 2b/16), computed exactly with a fixed table of 16 numbers. The reader below has the table.
A swap from method A to method B is priced by A's bid and its bid ladder, and by B's ask and its ask ladder:
- At the top, a unit of A gets A's bid over B's ask, in units of B.
- As the swap grows, each unit in gets at least the bid less the drop of the bid ladder's level it falls in, times one less the drop of the ask ladder's level, over the ask. The bid ladder counts what has come in; the ask ladder counts the same, in units of B at the top price.
- Beyond either ladder's last amount, the snapshot promises nothing.
A node prices each packet at its last unit. So a swap sent as one packet per step of the ladders gets at least the sum of the steps, and one packet across several steps gets the rate of the step its last unit falls in, on all of it.
The snapshot tells you what the node promised then. Its prices may have moved since, either way, so set a minimum on every packet you send.
How a reader walks the ladders, exactly
A small reader
This Node.js script fetches a node's discovery document, checks its ID, asks for its snapshot, checks it as above, and prints each method's quotes and what an amount gets from one method to another. It uses only Node's own modules, and knows the testnet's two hub schemes and the stub. Run it with the node's URL and an amount in the ledger's units; every ledger counts 109 units to one XRP or one dollar, and one of them is the default.
$node snapshot.mjs https://boot1.dassie.xyz
valid until 2026-10-28T12:47:19.243Z
hub-xrpl-testnet-dsusd: bid 1.027647614479065, ask 1.1433690786361694
hub-xrpl-testnet-xrp: bid 1.2857633829116821, ask 1.2857635021209717
1000000000 on hub-xrpl-testnet-dsusd gets at least 798994324 on hub-xrpl-testnet-xrp
1000000000 on hub-xrpl-testnet-xrp gets at least 1108266405 on hub-xrpl-testnet-dsusd
import { createHash, createPublicKey, verify } from "node:crypto";
import dgram from "node:dgram";
import { lookup } from "node:dns/promises";
const REQUEST_BYTES = 1180;
const MAX_DATAGRAM_BYTES = 1200;
const MAX_METHODS = 36;
const SNAPSHOT_OID = Buffer.from("0d2b0601040183a878058a770302", "hex");
const ID_OID = Buffer.from("0d2b0601040183a878058a770202", "hex");
const U64 = (1n << 64n) - 1n;
// floor(2^(62 + k/16)) for k from 0 to 15: a ladder's steps, in fixed point.
const STEPS = [
"4000000000000000", "42d561b3e6243d8a", "45cae0f1f545eb73", "48e1e9b9d588e19b",
"4c1bf828c6dc54b7", "4f7a993048d088d6", "52ff6b54d8a89c75", "56ac1f752150a563",
"5a827999fcef3242", "5e8451cfac061b5f", "62b39508aa836d6e", "6712460a8fc24071",
"6ba27e656b4eb57a", "70666f76154a7088", "75606373ee921c97", "7a92be8a92436616",
].map((hex) => BigInt(`0x${hex}`));
// The schemes this reader runs: it knows their methods by their tags.
const SCHEMES = ["hub-xrpl-testnet-xrp", "hub-xrpl-testnet-dsusd", "stub"];
// The node's ID is the hash of its two keys: check it before trusting either.
function nodeId(key, slhDsaKey) {
const input = Buffer.concat([ID_OID, Buffer.of(32), key, Buffer.of(32), slhDsaKey]);
const bytes = createHash("sha256").update(input).digest().subarray(0, 24);
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
let bits = 0, value = 0, text = "";
for (const byte of bytes) {
value = ((value << 8) | byte) & 0xfff;
bits += 8;
while (bits >= 5) text += alphabet[(value >>> (bits -= 5)) & 31];
}
return text + alphabet[(value << (5 - bits)) & 31];
}
// A method's tag: the first 4 bytes of a SHA-256 of a domain and its scheme's ID.
function methodTag(scheme) {
const hash = createHash("sha256").update("dassie-feed/method-tag").update(scheme);
return hash.digest().subarray(0, 4).toString("hex");
}
// A quote: m · 2^(e − 150), e in the top 8 bits, m normalized in the low 24.
function quote(bits) {
const m = bits & 0xffffff, e = bits >>> 24;
if (bits !== 0 && m < 0x800000) throw new Error("a quote is not normalized");
return { m: BigInt(m), e, value: m * 2 ** (e - 150) };
}
// previous · 2^(step/16), rounded down; undefined beyond a u64.
function stepUp(previous, step) {
const value = (previous * STEPS[step & 15]) >> BigInt(62 - (step >> 4));
return value > U64 ? undefined : value;
}
// A ladder: a 16-bit first amount (m · 2^e, e in the top 6 bits), then 7 steps.
function ladder(bytes) {
const bits = bytes.readUInt16BE(0), m = bits & 1023, e = bits >> 10;
if (e > 0 && (m < 512 || e > 54)) throw new Error("a first amount is not canonical");
const amounts = [BigInt(m) << BigInt(e)];
for (const step of bytes.subarray(2)) {
const value = stepUp(amounts.at(-1), step);
if (value === undefined) throw new Error("a ladder goes beyond a u64");
if (step > 0 && stepUp(amounts.at(-1), step - 1) === value) {
throw new Error("a step is not the smallest");
}
amounts.push(value);
}
return amounts;
}
// A snapshot datagram, checked against the node's key and the last one taken.
function readSnapshot(datagram, key, lastSequence, now) {
if (datagram.length > MAX_DATAGRAM_BYTES) throw new Error("too long");
let at = 0;
const take = (n) => {
if (at + n > datagram.length) throw new Error("too short");
return datagram.subarray(at, (at += n));
};
const [version, kind] = take(2);
if (version !== 2) throw new Error(`version ${version}`);
if (kind !== 0x81) throw new Error("not a snapshot");
const sequence = take(8).readBigUInt64BE(0);
const validUntil = take(8).readBigUInt64BE(0);
const grid = Array.from({ length: 8 }, () => take(2).readUInt16BE(0));
const [length, count] = take(2);
if (length !== 1 || count > MAX_METHODS) throw new Error("a bad method count");
const methods = [];
for (let n = 0; n < count; n++) {
const tag = take(4).toString("hex");
const bid = quote(take(4).readUInt32BE(0));
const ask = quote(take(4).readUInt32BE(0));
const bidLadder = ladder(take(9)), askLadder = ladder(take(9));
methods.push({ tag, bid, ask, bidLadder, askLadder });
}
const signature = take(64);
if (at !== datagram.length) throw new Error("bytes after the signature");
if (validUntil <= sequence) throw new Error("valid only until its sequence");
if (grid.some((drop, i) => i > 0 && drop <= grid[i - 1]) || grid[7] >= 10000) {
throw new Error("a bad grid");
}
if (methods.some((method, i) => i > 0 && method.tag <= methods[i - 1].tag)) {
throw new Error("methods out of order");
}
const closed = (q, amounts) => q.value === 0 && amounts.some((a) => a !== 0n);
if (methods.some((m) => closed(m.bid, m.bidLadder) || closed(m.ask, m.askLadder))) {
throw new Error("a closed side with a ladder");
}
if (lastSequence !== undefined && sequence <= lastSequence) throw new Error("old");
if (now >= validUntil) throw new Error("expired");
const signed = Buffer.concat([SNAPSHOT_OID, datagram.subarray(0, at - 64)]);
if (!verify(null, signed, key, signature)) throw new Error("a bad signature");
return { sequence, validUntil, grid, methods };
}
// m · 2^shift / d, rounded down, at most a u64.
function scaled(m, shift, d) {
const value = shift >= 0 ? (m << BigInt(shift)) / d : m / d >> BigInt(-shift);
return value > U64 ? U64 : value;
}
// The least `amount` in on method `from` gets out on method `to`, by the
// snapshot's ladders; undefined when it does not say.
function swapOut(snapshot, from, to, amount) {
const a = snapshot.methods.find((m) => m.tag === from);
const b = snapshot.methods.find((m) => m.tag === to);
if (from === to || !a || !b || a.bid.value === 0 || b.ask.value === 0) return undefined;
const { bid } = a, { ask } = b;
const keep = snapshot.grid.map((drop) => BigInt(10000 - drop));
// The ask ladder counts what came in at the top-of-book price.
const askTops = b.askLadder.map((out) => scaled(out * ask.m, ask.e - bid.e, bid.m));
// What n units in get out within level i of the bid ladder and j of the ask's.
const rate = (n, i, j) =>
scaled(n * bid.m * keep[i] * keep[j], bid.e - ask.e, ask.m * 10n ** 8n);
let i = 0, j = 0, paid = 0n, got = 0n;
while (i < 8 && j < 8 && paid < amount) {
const roomIn = a.bidLadder[i] - paid, roomOut = askTops[j] - paid;
if (roomIn <= 0n) { i++; continue; }
if (roomOut <= 0n) { j++; continue; }
let step = amount - paid;
if (roomIn < step) step = roomIn;
if (roomOut < step) step = roomOut;
paid += step;
got += rate(step, i, j);
if (got > U64) got = U64;
if (step === roomIn) i++;
if (step === roomOut) j++;
}
return paid === amount ? got : undefined;
}
const url = new URL(process.argv[2]);
const amount = BigInt(process.argv[3] ?? "1000000000");
const node = await (await fetch(new URL("/.well-known/dassie/node", url))).json();
const key = Buffer.from(node.publicKey, "base64url");
const slhDsaKey = Buffer.from(node.slhDsaPublicKey, "base64url");
if (nodeId(key, slhDsaKey) !== node.id) throw new Error("the ID is not the keys'");
if (node.feedPort === undefined) throw new Error("this node answers no request");
const verifyKey = createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: node.publicKey },
format: "jwk",
});
const names = new Map(SCHEMES.map((scheme) => [methodTag(scheme), scheme]));
const { address, family } = await lookup(url.hostname);
const socket = dgram.createSocket(family === 6 ? "udp6" : "udp4");
const request = Buffer.alloc(REQUEST_BYTES);
request.set([2, 0x80]);
let tries = 0;
const ask = () => {
if (++tries > 5) {
console.error("no answer after five requests");
process.exit(1);
}
socket.send(request, node.feedPort, address);
};
const timer = setInterval(ask, 2_000);
socket.on("message", (datagram, from) => {
if (from.address !== address || from.port !== node.feedPort) return;
let snapshot;
try {
snapshot = readSnapshot(datagram, verifyKey, undefined, BigInt(Date.now()));
} catch (error) {
return console.error(`refused a datagram: ${error.message}`);
}
clearInterval(timer);
socket.close();
console.log(`valid until ${new Date(Number(snapshot.validUntil)).toISOString()}`);
for (const m of snapshot.methods) {
console.log(`${names.get(m.tag) ?? m.tag}: bid ${m.bid.value}, ask ${m.ask.value}`);
}
for (const a of snapshot.methods) {
for (const b of snapshot.methods) {
const out = swapOut(snapshot, a.tag, b.tag, amount);
if (!names.has(a.tag) || !names.has(b.tag) || out === undefined) continue;
const [from, to] = [names.get(a.tag), names.get(b.tag)];
console.log(`${amount} on ${from} gets at least ${out} on ${to}`);
}
}
});
ask();
Node's Ed25519 follows RFC 8032 rather than ZIP-215. The two agree on
every signature a node makes; they differ only on odd signatures no
node writes. In Rust, the
dassie-feed
crate has the whole reader's side: SnapshotReader does
every check, and Snapshot::swap_out,
packet_out and swap_at_least price swaps.
It lives in the repository and is not on crates.io.
Test vectors
testdata/feed/snapshots.json
pins the format. Its datagrams are signed with one fixed key, whose
seed and publicKey it gives. It holds:
- method tags of four scheme IDs, and quotes, first amounts and ladders with their bytes and values;
- the step table, and the request;
- four snapshots your reader must take, with their decoded fields: a heartbeat with no methods, one with two methods, one with a closed side, and the largest the format allows, 36 methods;
- swaps priced from the two-method snapshot: what an amount gets, what one packet gets from a point in the swap, and the largest swap that still gets a given rate;
- fourteen datagrams it must refuse, each with the reason: bad signatures, a weak key, version 1, a snapshot for each broken rule, and two bad requests.
The small snapshots expired long ago, and the largest is valid until the largest u64, so test your decoding and signature check with the vectors, and your clock check apart. Dassie's own code makes the vectors, since no other implementation exists yet; running its generator again gives the same file. A changed vector would mean a new version of the format.
When nothing arrives
-
No
feedPortin the discovery document: the node has no domain name, its owner offers no swaps, it has no UDP socket for requests, or it has no snapshot that counts right now, as while it starts. -
No answer: a firewall drops UDP to the port, on
the node's side or yours; the default port is 7768. Check that your
request is exactly 1180 bytes, starts
02 80and is zeros after that. Otherwise the node may have spent its budget for the moment: ask again shortly. - A snapshot with no methods: the node is alive, but offers its peers no settlement method yet.
- Methods with quotes of zero: the node offers the method, but does not convert that side right now.
- Tags you don't know: methods of schemes you don't run. You can't use them, so skip them.
If you run the node, dassie status has a Rate feed
line: where the node answers, how many datagrams it took, answered,
refused for the budget and ignored, and how many sends failed. Under systemd the
socket is dassie-feed.socket, on UDP port 7768; open it
in your firewall. See Check on your node and
Files, ports and sockets.