Binding
The four-signature handshake that ties a node to an operator, and why both sides must consent.
Binding answers one question: who is responsible for this node, and under what limits?
Both sides must agree. An operator cannot claim a node, and a node cannot attach itself to an operator who did not consent. That symmetry is the whole design.
The four signatures
1. node signs "here is what I am and what I want" → BindingSession
2. operator signs "this device key may act for me" → DeviceCredential (root key, offline, once)
3. device key signs "this node, these limits" → AuthorizationCredential
4. node signs "I accept this specific authorization" → AcceptanceBefore a binding becomes ACTIVE, the registry re-verifies all four, atomically. Not incrementally as they arrive — all of them, together, at the end. A partial chain never activates anything.
The flow
- The node opens a session
POST /v1/bindings/initiateThe node signs a canonical description of itself — identity, device summary, OS, region, the task types and limits it wants — and receives two things back:
json { "binding_session_id": "...", "short_code": "K7M2NQ4X", "binding_token": "3Yk9...", "expires_at": "2026-07-31T16:43:08Z", "state": "INITIATED" }AvertissementBoth are returned exactly once and never again. The registry stores only their SHA-256 hashes. If you lose them, cancel the session and start over — there is no recovery path, by design.
The short code uses the alphabet
23456789ABCDEFGHJKMNPQRSTUVWXYZ— noI,O,0or1— so it survives being read aloud over a phone.The registry enforces
0 < expires_at − created_at ≤ 600s. A node cannot grant itself a longer window than the protocol allows. - The operator redeems the code
POST /v1/bindings/resolveThe console shows what the node is asking for, plus a fingerprint of its key:
json { "node_did": "did:svrp:n:8545027b", "node_public_key_fingerprint": "8545:027b:6100:1591", "device_summary": "MacBook Pro", "os": "darwin", "requested": { "task_types": ["inference"], "single_limit": null, "daily_limit": null } }AvertissementCompare that fingerprint against the node's own screen before continuing.
This is the human step, and it is the one that actually prevents you from adopting a node someone else controls. Everything downstream is cryptography; this is the part where a person confirms the two machines are the two machines they think they are.
- The operator authorizes
POST /v1/bindings/authorizeThe device key signs an AuthorizationCredential naming the node and its limits. The registry verifies the chain root → device → authorization, and rejects any authorization whose scope exceeds what the node asked for.
The root key is not involved. It signed the DeviceCredential once, offline, and stays in the safe.
- The node accepts
POST /v1/node/bindings/{id}/acceptThe node polls for pending authorizations, checks that the terms are the ones it asked for, and signs its acceptance. The registry re-verifies all four signatures and the binding becomes
ACTIVE.A failure here reverts the session to
PENDING_NODE_ACCEPTANCErather than destroying it — a transient client bug should not burn the binding.
The state machine
INITIATED ──resolve──▶ PENDING_OPERATOR ──authorize──▶ PENDING_NODE_ACCEPTANCE
│
accept │ reject
▼
VERIFYING
│
▼
ACTIVETerminal states — ACTIVE, EXPIRED, REJECTED_BY_OPERATOR, REJECTED_BY_NODE, CANCELLED — are final. A session never moves again once it reaches one.
const request = await ln.binding.resolve({ shortCode: "K7M2NQ4X" });
console.log("Compare on the node's screen:", request.node_public_key_fingerprint);
// ...operator authorizes via the Console...
const final = await ln.binding.waitForTerminal(request.binding_session_id);
console.log(final.state); // ACTIVE | REJECTED_BY_NODE | EXPIRED | ...request = ln.binding.resolve(short_code="K7M2NQ4X")
print("Compare on the node's screen:", request.node_public_key_fingerprint)
final = ln.binding.wait_for_terminal(request.binding_session_id)
print(final.state)Anti-enumeration, deliberately
A short code is eight characters from a 31-symbol alphabet. That is guessable if you are allowed to guess a lot, so you are not:
- Five failed attempts on a code within 600 seconds returns
429 RATE_LIMITED. Every failure returns the same generic
404. Invalid, expired and already-used are indistinguishable from the outside.
That second point is the important one, and it is worth the worse error message: if "expired" and "not found" were distinguishable, the endpoint would be an oracle for testing whether a code was ever real. So a wrong code and a stale code look identical, on purpose.
Signing is not in the SDKs, on purpose
initiate, authorize and accept are not reimplemented in the TypeScript or Python SDKs. Calling binding.authorize() raises with an explanation rather than attempting it.
The canonical bytes these steps sign must match the registry's reconstruction exactly — down to Python's str() rendering of booleans as True/False, and to field ordering that is not your JSON library's. A near-miss does not fail loudly; it produces a signature that verifies nowhere, and the error surfaces three hops from its cause.
Two audited implementations already exist. Use one:
operator-console/lib/crypto/canonical.ts— the browser Consoleln-node bind— the node daemon
Re-deriving canonicalisation from a prose description is how you spend a week debugging arithmetic that was never wrong.
The SDKs do implement everything that needs no signing: resolve, status, cancel, waitForTerminal, and the fingerprint helper.
After binding
GET /v1/operator/me/nodes lists your bound nodes with a computed online flag and the limits agreed at binding time. It also reconciles the short/full DID encodings for you — see identity for why that matters.
Binding needs an operator identity. Here is how login works, end to end.