SolverHub API
Implemented authentication, HTTP endpoints, WebSocket messages, order data, and key rotation for custom SolverHub clients.
Interfaces
SolverHub is Anyflo’s hosted solver integration interface. Obtain these environment-specific settings during onboarding:
| Setting | Meaning |
|---|---|
HUB_URL | HTTPS origin, without /v1, /connect, or a trailing slash. |
HUB_WS_URL | Full WSS URL, including /connect. |
SOLVER_ID | Your assigned, active solver’s positive numeric identity. |
| Authentication key | Registered Ed25519 public key and its positive integer key ID (kid); retain the private key securely. |
This page describes implemented behavior, not the proposed REST bidding redesign. Bids and assignment acceptance use WebSocket messages. There are no implemented REST bid/accept or resume endpoints in this contract.
Solver credentials are separate from customer Gateway credentials: do not send Solver JWTs to the customer Gateway or use anyflo_sk_ customer keys for SolverHub. Anyflo provisions the first solver identity and public key. Gateway administration, service-binding RPC, database access, and deployment commands are not LP integration endpoints.
The supplied standalone application’s private loopback interface is a different API: see Reference Solver — Local operator API.
Authentication
Every protected HTTP request and WebSocket upgrade carries Authorization: Bearer <jwt>. The solver issues its own Ed25519-signed JWT using a registered active key.
Header, with an example numeric key ID:
{"alg":"EdDSA","typ":"JWT","kid":1}
Claims:
interface JwtClaims {
sub: string; // Assigned solver ID, for example "123".
aud: string; // Must be "anyflo:solver-hub".
iat: number; // Integer Unix seconds.
exp: number; // Integer Unix seconds; iat < exp <= iat + 900.
jti?: string; // Token identifier, not a per-message replay nonce.
}
Base64url-encode the UTF-8 JSON header and claims, join them with ., sign those ASCII bytes with Ed25519, then append . and the base64url signature. The Hub verifies the received bytes rather than reserializing the JSON. Its clock-skew allowance is four seconds. Synchronize clocks and mint a fresh short-lived JWT for each HTTP call and reconnect. An established WebSocket does not close solely because its upgrade JWT expires.
Bun JWT example
These helpers are conveniences, not protocol requirements for clients in other languages. The reference Solver is a standalone application, not an embeddable SDK.
Create a small local example project. First configure scoped registry access, and set LIB_VERSION to an exact released version confirmed by Anyflo; a repository manifest is not proof of a published release.
mkdir -p solverhub-client
cd solverhub-client
bun init -y
bun add --exact "@anyflo/lib@$LIB_VERSION" cbor-x
Securely inject SOLVER_ID and SOLVER_AUTH_PRIVATE_KEY for this example. These reuse the Reference Solver’s secret format, not SolverHub request properties: SOLVER_AUTH_PRIVATE_KEY is a JSON object {kid,jwk} containing your private Ed25519 JWK. Custom clients may store credentials differently. Do not paste it into shell history, logs, or support messages. Save the following as solver-jwt.ts:
import { signJwt } from "@anyflo/lib/auth";
import { fromJwk, type Jwk } from "@anyflo/lib/ed25519";
export async function loadIdentity(): Promise<{
solverId: number;
kid: number;
privateKey: CryptoKey;
}> {
const id = process.env.SOLVER_ID;
const secret = process.env.SOLVER_AUTH_PRIVATE_KEY;
if (!id || !/^[0-9]+$/.test(id) || !secret) {
throw new Error("SOLVER_ID and SOLVER_AUTH_PRIVATE_KEY are required");
}
const solverId = Number(id);
if (!Number.isSafeInteger(solverId) || solverId <= 0) {
throw new Error("SOLVER_ID must be a positive safe integer");
}
let record: unknown;
try {
record = JSON.parse(secret);
} catch {
throw new Error("SOLVER_AUTH_PRIVATE_KEY must contain valid JSON");
}
if (!record || typeof record !== "object") {
throw new Error("Invalid authentication key record");
}
const { kid, jwk } = record as { kid?: unknown; jwk?: unknown };
if (typeof kid !== "number" || !Number.isSafeInteger(kid) || kid <= 0 ||
!jwk || typeof jwk !== "object") {
throw new Error("A positive integer kid and private JWK are required");
}
const key = jwk as Record<string, unknown>;
if (key.kty !== "OKP" || key.crv !== "Ed25519" ||
typeof key.x !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(key.x) ||
typeof key.d !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(key.d)) {
throw new Error("An Ed25519 private JWK is required");
}
let privateKey: CryptoKey;
try {
privateKey = await fromJwk(key as Jwk);
} catch {
throw new Error("Cannot import authentication private key");
}
return { solverId, kid, privateKey };
}
export async function mintJwt(): Promise<string> {
const { solverId, kid, privateKey } = await loadIdentity();
return signJwt(solverId, kid, privateKey, 60);
}
if (import.meta.main) {
try {
console.log(await mintJwt());
} catch {
console.error("JWT generation failed; check identity and key configuration");
process.exitCode = 1;
}
}
The helper’s full signature is signJwt(solverId: number, kid: number, privateKey: CryptoKey, ttlSec: number, audience?: string): Promise<string>. The example uses a 60-second lifetime and the default SolverHub audience. Successful stdout contains only the JWT, never the private key. Treat the JWT as an ephemeral secret too.
HTTP API
Choose JSON explicitly with Accept: application/json. CBOR is the default when Accept is omitted or wildcard. JSON represents BigInts as decimal strings. Body requests must also set Content-Type: application/json (or the supported CBOR content type); a bodyless GET needs no Content-Type.
| Method and path | Request type | Success | Important errors |
|---|---|---|---|
POST /v1/keys | RegisterKeyBody | HTTP 200 null | 400 invalid body/key; 409 kid already used, including revoked keys. |
POST /v1/keys/revoke | RevokeKeyBody | HTTP 200 RevokeKeyRes | 404 specified key absent or already revoked; revoke-all is idempotent. |
GET /v1/orders/active | No body; identity comes from JWT | HTTP 200 ActiveOrdersResp | Downstream failures are errors, not empty success results. |
GET /connect | WebSocket upgrade with bearer JWT | HTTP 101 | 426 for an authenticated request without WebSocket upgrade. |
TypeScript equivalents of the HTTP schemas; JSON encodes BigInts inside OrderDetails as decimal strings:
import type { OrderDetails } from "@anyflo/lib/order";
interface RegisterKeyBody {
kid: number; // Positive integer; must not have been used before.
publicKey: {
kty: "OKP";
crv: "Ed25519";
x: string; // Base64url public key. Never include private field d.
};
}
interface RevokeKeyBody {
kid?: number; // Positive integer; omission revokes all keys.
}
interface RevokeKeyRes {
solverId: number;
revoked: number; // Number of keys revoked.
}
interface ActiveOrdersResp {
orders: OrderDetails[]; // An empty array is a valid success.
}
All protected routes can return 401 for missing, invalid, expired, or revoked credentials, and 403 for an inactive solver. With negotiated JSON, authentication responses are bare strings "not-authenticated" and "solver-not-active", not customer API {error:...} envelopes. Body endpoints can return 415 for a missing/unsupported Content-Type, or 400 for malformed or schema-invalid JSON/CBOR. Other validation errors need not have the authentication string shape.
Discover the environment’s generated reference at /v1/docs and /v1/openapi.json on HUB_URL; these discovery paths are exempt from Solver authentication. Runtime behavior and the examples here take precedence where generated common error schemas differ.
Register a subsequent public key
Generate a new unused key ID and keypair first; keep the private half securely. Write only this public request shape to new-public-key.json, replacing the illustrative x placeholder with the generated public key:
{
"kid": 2,
"publicKey": {
"kty": "OKP",
"crv": "Ed25519",
"x": "<base64url public key from the new keypair>"
}
}
The onboarding generator’s record {solverId,kid,jwk} is not this request body: convert it to {kid,publicKey:jwk} using only the public JWK. Never send a JWK containing d, and do not include solverId in the request.
Authenticate with the current active key:
SOLVER_JWT="$(bun run solver-jwt.ts)"
curl --fail-with-body "$HUB_URL/v1/keys" \
-X POST \
-H "Authorization: Bearer $SOLVER_JWT" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data-binary @new-public-key.json
Successful JSON is null (HTTP 200).
Fetch active assigned orders
SOLVER_JWT="$(bun run solver-jwt.ts)"
curl --fail-with-body "$HUB_URL/v1/orders/active" \
-H "Authorization: Bearer $SOLVER_JWT" \
-H 'Accept: application/json'
An empty response is valid:
{"orders":[]}
Here is an illustrative nonempty snapshot containing an OrderDetails object whose order property is an Order, not a live response. IDs, addresses, tokens, amounts, and times are examples, not provisioned route settings:
{
"orders": [{
"order": {
"id": "123e4567-e89b-42d3-a456-426614174000",
"senderId": "123e4567-e89b-42d3-a456-426614174001",
"recipientId": null,
"senderAddr": "0x1111111111111111111111111111111111111111",
"recipientAddr": "0x2222222222222222222222222222222222222222",
"senderChain": "eip155:84532",
"recipientChain": "eip155:84532",
"tokenIn": "0x3333333333333333333333333333333333333333",
"amountIn": "1000000",
"tokenOut": "0x3333333333333333333333333333333333333333",
"amountOut": "998000",
"typ": 1,
"status": "selected",
"solverId": 123,
"solverAddress": null,
"directDeposit": false,
"fee": null,
"feeRecipient": null,
"depositExpiresAt": null,
"createdAt": 1794441600000,
"updatedAt": 1794441600000
}
}]
}
Revoke the old key after switching
After registering kid 2, switch your injected identity to kid 2 and verify it works before executing this example, which revokes kid 1:
SOLVER_JWT="$(bun run solver-jwt.ts)"
curl --fail-with-body "$HUB_URL/v1/keys/revoke" \
-X POST \
-H "Authorization: Bearer $SOLVER_JWT" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data '{"kid":1}'
Illustrative success: {"solverId":123,"revoked":1}. Sending {} revokes all keys and can lock you out; it is not the normal rotation procedure. See Key rotation.
WebSocket protocol
Use a server-side client capable of setting Authorization on the upgrade to HUB_WS_URL. Browser WebSocket APIs cannot set this header. Binary application frames use cbor-x CBOR. Handle the text frame "pong" before attempting binary decoding.
Send text "ping" every 30,000 ms. When selecting live connections, the Hub considers sockets stale after more than 60,000 ms without heartbeat (including a never-pinged connection’s grace period). The reference client reconnects after more than 90,000 ms without incoming frames.
Hub envelopes are {type,payload}. Solver envelopes are {type,payload}, with a sibling sig: Uint8Array on the two signed variants. There is no JSON-RPC ID, generic RPC result, or guaranteed per-message error response. Correlate BidAck using payload.orderId, payload.solverId, and payload.quote, not an invented request ID. Below, message-property notation such as NewOrder.order refers to payload.order on the envelope whose type is "NewOrder".
Connection-only Bun example
Save as solver-connect.ts beside solver-jwt.ts. This is a complete authenticated receive/heartbeat example, not a bidding or payout implementation. It deliberately sends no financial commitment or wallet transaction.
import { decode } from "cbor-x";
import { FromHubMsgSchema } from "@anyflo/lib/solverhub";
import { mintJwt } from "./solver-jwt";
const configuredUrl = process.env.HUB_WS_URL;
if (!configuredUrl) throw new Error("HUB_WS_URL is required");
const url = new URL(configuredUrl);
if (url.protocol !== "wss:" || url.pathname !== "/connect" ||
url.username || url.password) {
throw new Error("HUB_WS_URL must be an assigned WSS /connect URL");
}
const jwt = await mintJwt();
const socket = new WebSocket(url.href, {
headers: { Authorization: `Bearer ${jwt}` },
});
socket.binaryType = "arraybuffer";
let timer: ReturnType<typeof setInterval> | undefined;
let lastFrameAt = Date.now();
const clearHeartbeat = () => {
if (timer !== undefined) clearInterval(timer);
timer = undefined;
};
socket.addEventListener("open", () => {
lastFrameAt = Date.now();
console.log("SolverHub connected");
timer = setInterval(() => {
if (Date.now() - lastFrameAt > 90_000) {
clearHeartbeat();
socket.close(4003, "Heartbeat timeout");
return;
}
if (socket.readyState === WebSocket.OPEN) socket.send("ping");
}, 30_000);
});
socket.addEventListener("message", (event) => {
lastFrameAt = Date.now();
if (event.data === "pong") return;
if (!(event.data instanceof ArrayBuffer)) {
console.error("Unexpected non-binary SolverHub frame");
return;
}
try {
const message = FromHubMsgSchema.parse(decode(new Uint8Array(event.data)));
console.log("SolverHub message:", message.type);
} catch {
console.error("Invalid SolverHub application frame");
}
});
socket.addEventListener("close", (event) => {
clearHeartbeat();
console.log("SolverHub closed; code:", event.code);
});
socket.addEventListener("error", () => {
clearHeartbeat();
console.error("SolverHub transport error");
socket.close();
});
Run with securely injected identity and assigned URL:
bun run solver-connect.ts
A complete client must also implement catch-up and reconciliation, bounded reconnect backoff, and durable payout state; logging a received message is not order fulfillment.
Message catalogue
Message payload chain fields use a supported chain ID from the shared ChainSchema (for example eip155:84532), not an arbitrary display name; see Reference Solver supported chains. Message orderId fields identify Order.id and are UUIDv4. Transaction hash strings have length 1–256.
Hub → solver
The payload types are expanded inside each envelope below for readability.
import type { Chain } from "@anyflo/lib/chains";
import type { Order, TxHash } from "@anyflo/lib/order";
import type { UUID } from "@anyflo/lib/uuid";
type FromHubMsg =
| {
type: "NewOrder";
payload: { order: Order; biddingDeadline: number }; // Epoch ms.
}
| {
type: "OrderAssigned";
payload: { orderId: UUID; winnerId: number; order: Order };
}
| {
type: "OrderDeposit";
payload: { orderId: UUID; depositTx: TxHash; chain: Chain };
}
| {
type: "OrderDelivery";
payload: { orderId: UUID; deliverTx: TxHash; chain: Chain };
}
| {
type: "OrderDepositReorged" | "OrderDeliveryReorged";
payload: {
orderId: UUID;
txHash: TxHash;
chain: Chain;
blockNumber: number; // Nonnegative integer.
};
}
| {
type: "OrderReassigned";
payload: { orderId: UUID; reason: string };
}
| {
type: "OrderCancelled";
payload: { orderId: UUID; solverId: number; reason: string };
}
| {
type: "BidAck";
payload: {
orderId: UUID;
solverId: number; // Positive integer.
quote: bigint;
rank: number;
};
}
| {
type: "KeyRevoked";
payload: {
solverId: number; // Positive integer.
kid: number | null; // Positive integer; null means all keys.
};
}
| {
type: "SolverSuspended";
payload: { solverId: number }; // Positive integer.
};
Every solver payload has common fields orderId: UUIDv4 and timestamp: number in epoch milliseconds. Strict schemas reject undeclared fields, including solverId; identity comes from the authenticated socket. Signed payload kid selects a registered active key belonging to that solver.
Solver → Hub
import type { TxHash } from "@anyflo/lib/order";
import type { UUID } from "@anyflo/lib/uuid";
type ToHubMsg =
| {
type: "Bid";
payload: {
orderId: UUID;
timestamp: number; // Epoch ms.
quote: bigint; // Positive, in token base units.
expiresAt: number; // Epoch ms.
kid: number; // Positive integer.
};
sig: Uint8Array; // Signature over the complete payload only.
}
| {
type: "OrderAssignAck";
payload: {
orderId: UUID;
timestamp: number; // Epoch ms.
solverAddress: string; // Source-chain receiving address.
directDeposit: boolean;
kid: number; // Positive integer.
};
sig: Uint8Array;
}
| {
type: "OrderAssignNotAck";
payload: {
orderId: UUID;
timestamp: number; // Epoch ms.
reason: string; // Trimmed length 1–256.
};
}
| {
type: "OrderDepositAck" | "OrderDepositNotAck" | "OrderDeliveryAck";
payload: { orderId: UUID; timestamp: number }; // Epoch ms.
}
| {
type: "OrderDeliveryNotAck";
payload: {
orderId: UUID;
timestamp: number; // Epoch ms.
deliverTx: TxHash;
};
};
Timestamps must be within ±10,000 ms of Hub time. The replay fingerprint is BLAKE3 of the deterministic payload, scoped to the authenticated solver and cached for 25,000 ms. To retry, advance the timestamp and re-sign signed payloads. Freshness does not make repeating a business operation safe.
Signing bids and acceptance
Sign the complete payload only with Ed25519 over deterministicEncode(payload). The helper uses cbor2 with {dcbor:true}, following RFC 8949 §4.2. Do not sign the outer {type,payload,sig} envelope, JSON text, or the raw cbor-x transport encoding. sig is outside the payload and is transmitted as bytes, not a hexadecimal/base64 string.
Bid.quote is a positive BigInt in token base units: Order.tokenOut units for fixed-input orders (Order.typ:1), Order.tokenIn units for fixed-output orders (Order.typ:2). Never use a floating-point JavaScript number. Bid.expiresAt is epoch milliseconds; it is distinct from NewOrder.biddingDeadline and JWT claim exp, which uses seconds.
Save these complete helpers as solver-commitments.ts:
import { encode } from "cbor-x";
import { deterministicEncode, sign } from "@anyflo/lib/ed25519";
import {
MsgToHubBidSchema,
MsgToHubOrderAssignAckSchema,
} from "@anyflo/lib/solverhub";
export async function sendBid(
socket: WebSocket,
input: unknown,
privateKey: CryptoKey,
): Promise<void> {
if (socket.readyState !== WebSocket.OPEN) throw new Error("Socket is not open");
const payload = MsgToHubBidSchema.parse(input);
if (payload.quote <= 0n) throw new Error("Quote must be positive");
const sig = await sign(privateKey, deterministicEncode(payload));
if (socket.readyState !== WebSocket.OPEN) throw new Error("Socket closed while signing");
socket.send(encode({ type: "Bid", payload, sig }));
}
export async function acceptAssignment(
socket: WebSocket,
input: unknown,
privateKey: CryptoKey,
): Promise<void> {
if (socket.readyState !== WebSocket.OPEN) throw new Error("Socket is not open");
const payload = MsgToHubOrderAssignAckSchema.parse(input);
const sig = await sign(privateKey, deterministicEncode(payload));
if (socket.readyState !== WebSocket.OPEN) throw new Error("Socket closed while signing");
socket.send(encode({ type: "OrderAssignAck", payload, sig }));
}
The following usage functions populate valid payload shapes from actual received NewOrder and OrderAssigned payloads and explicit operator decisions. They do not invent an existing order or silently choose an address/price. Save as commitment-usage.ts; a real lifecycle handler calls these only after checking route support, inventory, deposit policy, and its durable local state.
import type {
MsgFromHubNewOrder,
MsgFromHubOrderAssigned,
} from "@anyflo/lib/solverhub";
import { acceptAssignment, sendBid } from "./solver-commitments";
type Identity = { solverId: number; kid: number; privateKey: CryptoKey };
export async function bidOnReceivedOrder(
socket: WebSocket,
received: MsgFromHubNewOrder,
identity: Identity,
operatorQuote: bigint,
operatorQuoteExpiresAt: number,
): Promise<void> {
const now = Date.now();
if (now >= received.biddingDeadline || operatorQuoteExpiresAt <= now) {
throw new Error("Bidding deadline or quote expiry has passed");
}
await sendBid(socket, {
orderId: received.order.id,
quote: operatorQuote,
expiresAt: operatorQuoteExpiresAt,
kid: identity.kid,
timestamp: now,
}, identity.privateKey);
}
export async function acceptReceivedAssignment(
socket: WebSocket,
received: MsgFromHubOrderAssigned,
identity: Identity,
storedBidQuote: bigint,
solverAddress: string,
directDeposit: boolean,
): Promise<void> {
const assignedQuote = received.order.typ === 1
? received.order.amountOut : received.order.amountIn;
if (received.winnerId !== identity.solverId ||
received.orderId !== received.order.id || assignedQuote !== storedBidQuote) {
throw new Error("Assignment does not match this solver's stored bid");
}
if (!solverAddress.trim()) throw new Error("OrderAssignAck.solverAddress is required");
await acceptAssignment(socket, {
orderId: received.orderId,
solverAddress,
directDeposit,
kid: identity.kid,
timestamp: Date.now(),
}, identity.privateKey);
}
For example, operatorQuote = 998000n supplies Bid.quote as 998,000 raw Order.tokenOut units for a fixed-input order; this is an illustrative price, not a recommended quote. The solverAddress argument supplies OrderAssignAck.solverAddress: the operator’s receiving address on Order.senderChain, recorded as Order.solverAddress after acceptance. It is not Order.recipientAddr, the payout recipient on Order.recipientChain. The wallet that signs the destination payout is client-managed, not an Order property; the Reference Solver loads it from its configured chain’s wallet secret, not from deliveryAccountAddress (the delivery contract address). Explanatory JSON-shaped envelopes are only representations: actual application transmission is CBOR with BigInts and byte signatures.
Unsigned responses use the same transport encoding, without sig. For a real received order, the following defined helper builds and schema-validates the exact unsigned variants; the caller must select the response justified by its lifecycle state:
import { encode } from "cbor-x";
import { ToHubMsgSchema } from "@anyflo/lib/solverhub";
type UnsignedResponse =
| { type: "OrderAssignNotAck"; reason: string }
| { type: "OrderDepositAck" | "OrderDepositNotAck" | "OrderDeliveryAck" }
| { type: "OrderDeliveryNotAck"; deliverTx: string };
export function sendUnsignedResponse(
socket: WebSocket,
orderId: string,
response: UnsignedResponse,
): void {
if (socket.readyState !== WebSocket.OPEN) throw new Error("Socket is not open");
const { type, ...fields } = response;
const message = ToHubMsgSchema.parse({
type,
payload: { orderId, timestamp: Date.now(), ...fields },
});
socket.send(encode(message));
}
Recovery and lifecycle
- Bid before
NewOrder.biddingDeadline(default bidding window 1,500 ms). ValidateOrderAssigned.winnerIdagainst your solver identity andOrderAssigned.order.amountOut(Order.typ:1) orOrderAssigned.order.amountIn(Order.typ:2) against your savedBid.quote, then sendOrderAssignAckorOrderAssignNotAckwithin the default 1,500 ms acceptance window.OrderAssignAck.solverAddresssupplies the source receiving address;OrderAssignAck.directDepositsupplies the receipt mode recorded inOrder. Failed acceptance can lead to reassignment; the best numerical quote is not a selection guarantee. OrderDepositAckacknowledges notification receipt, not successful verification. VerifyOrderDeposit.depositTxonOrderDeposit.chainagainst the assignedOrderand required finality before payingOrder.amountOutofOrder.tokenOuttoOrder.recipientAddronOrder.recipientChain. Report failed verification withOrderDepositNotAck.- Persist payout identity and the submitted transaction hash before treating retries/reconnects as new work. Reconcile
OrderDelivery.deliverTxandOrderDelivery.chainagainst your own saved hash and chain evidence.OrderDeliveryAckis not a substitute for on-chain proof; report an unrecognized/mismatched delivery withOrderDeliveryNotAck.deliverTx. - Stop work on reassignment/cancellation; investigate deposit/delivery reorg notices and reconcile already submitted transactions. Never blindly submit a second payout.
- Connect with a fresh JWT and bounded reconnect backoff. The reference uses 100/500/1,000 ms delays, then a 1.5× factor capped at 60,000 ms. Fetch
/v1/orders/activeon initial connection and every reconnection and reconcile the snapshot with durable local state and arriving notices before acting. Snapshot retrieval failure is not evidence of no active orders.
Targeted assignment, deposit, delivery, reorg, reassignment, and cancellation notices can be buffered for up to one hour. Buffered notices are removed after send, not application acknowledgement. Broadcasts and BidAck are not durable. There is no sequence number, Resume, or guaranteed replay/catch-up stream; a snapshot alone also does not prove what your wallet previously submitted.
| Close code | Meaning and recovery |
|---|---|
4001 | Key revoked. Use a working registered key, rotate, or request Anyflo-assisted recovery if no active key remains. Blind reconnect with the revoked key fails. |
4002 | Solver suspended. Obtain Anyflo reactivation, then restart the reference process (or re-enable a deliberately stopped custom client). Key rotation does not reactivate the solver. |
4003 | Heartbeat timeout. Reconnect with a fresh JWT and reconcile active orders. |
Order data
Order is the object in NewOrder.order and OrderAssigned.order; see the message catalogue. An active-order HTTP response contains OrderDetails wrappers instead. BigInts below are native BigInts in application code/CBOR and decimal strings in JSON. Token amounts are raw base units; do not interpret them as USD or assume a fixed decimal count. Chain IDs and token identifiers must match the provisioned environment.
Unless marked optional, fields are present. Nullable means the field may explicitly be null; it does not mean the field may be omitted.
import type { Chain } from "@anyflo/lib/chains";
import type { TxHash } from "@anyflo/lib/order";
import type { UUID } from "@anyflo/lib/uuid";
type OrderStatus =
| "new"
| "rejected"
| "expired"
| "bidding"
| "selected"
| "accepted"
| "executing"
| "delivered"
| "depositNotAck"
| "deliveredNotAck"
| "cancelled";
interface Order {
id: UUID;
senderId: UUID;
recipientId: UUID | null;
senderAddr: string;
recipientAddr: string; // Destination payout recipient, not the signer.
senderChain: Chain;
recipientChain: Chain;
tokenIn: string;
amountIn: bigint; // Solver's source receipt, in tokenIn base units.
tokenOut: string;
amountOut: bigint; // Payout to recipientAddr, in tokenOut base units.
typ: 1 | 2; // Fixed input (1): quote output; fixed output (2): quote input.
status: OrderStatus;
solverId: number | null;
solverAddress: string | null; // Source receiving address from OrderAssignAck.
directDeposit: boolean; // true: direct receipt; false: contract credit.
fee: bigint | null; // Anyflo fee, not the solver's quote premium.
feeRecipient: string | null;
depositExpiresAt: number | null; // Epoch ms.
createdAt: number; // Epoch ms.
updatedAt: number; // Epoch ms.
depositTx?: TxHash | null;
deliverTx?: TxHash | null;
reorgedDeliveryTxs?: TxHash[];
depositReorgedAt?: number | null; // Epoch ms.
}
The shared parser defaults directDeposit to false if omitted. selected awaits solver acceptance; accepted awaits source deposit; executing follows source deposit confirmation. depositNotAck and deliveredNotAck are dispute states and must not be silently treated as completed normal flow.
The destination payout signing wallet is not a field of Order. For the Reference Solver, see wallet secrets and wallets and approvals; chain configuration deliveryAccountAddress identifies a delivery contract, not that wallet.
OrderDetails
An OrderDetails wrapper contains a required order and optional, non-nullable instructions and transaction references:
import type { Order, OrderInstructions, OrderTxs } from "@anyflo/lib/order";
interface OrderDetails {
order: Order;
instructions?: OrderInstructions;
txs?: OrderTxs;
}
Deposit instructions
OrderDetails.instructions is an OrderInstructions object. All fields are required when the object is present:
interface OrderInstructions {
target: string; // Deposit target.
tokens: string[]; // Token identifiers.
amounts: bigint[]; // Raw token base units.
fees: bigint[]; // Raw token fee base units.
directDeposit: boolean;
solverAddress: string; // Minimum length 3; source-chain receiving address.
depositExpiresAt: number; // Epoch ms.
signature: string; // Deposit signature, not a WebSocket sig byte array.
}
solverAddress corresponds to OrderDetails.order.solverAddress on OrderDetails.order.senderChain.
Transaction references
OrderDetails.txs contains required deposit and delivery arrays. The timestamp is the indexed on-chain timestamp, not a transaction nonce or ordering field.
import type { TxHash } from "@anyflo/lib/order";
interface OrderTxRef {
txHash: TxHash; // String, length 1–256.
timestamp: number; // Indexed on-chain timestamp.
}
interface OrderTxs {
depositTxs: OrderTxRef[]; // Source deposits.
deliverTxs: OrderTxRef[]; // Destination payouts.
}
Key rotation
Key IDs are positive integers unique within a solver. Use increasing IDs operationally and never reuse a revoked ID. There is no rotate endpoint: rotation is add-new → switch → revoke-old.
- Generate a fresh Ed25519 keypair with unused kid 2. Securely retain its private JWK and public registration record. Reference Solver’s CLI can generate these; a custom client may use compatible Ed25519 key generation instead.
- With current kid 1 in
SOLVER_AUTH_PRIVATE_KEY, register kid 2 throughPOST /v1/keys, sending only{kid,publicKey}. Registration does not disable kid 1. - Switch the injected secret to kid 2. Restart the reference process so it reloads the key, or reconnect your custom client with a freshly signed kid-2 JWT. Verify HTTP authentication and WebSocket connectivity with kid 2 before retiring kid 1.
- Authenticate with kid 2 and revoke kid 1 through
POST /v1/keys/revoke. The Hub best-effort sendsKeyRevokedand closes sockets opened with the revoked key with code 4001.
Registration and revocation propagate eventually; do not assume instantaneous global cutover. Allow an overlap and verify the new key. A stolen JWT remains a secret; short expiry limits exposure but does not replace revocation.
Revoking all keys uses {} and closes all solver sockets (KeyRevoked.kid is null). If you revoke the last key or lose every active private key, you cannot authenticate to add another key: request Anyflo-assisted identity verification and recovery. Initial provisioning/recovery is not anonymous self-service. Suspension is separate from key revocation and requires business-status reactivation, not a new key.
See Reference Solver secrets for secure injection and restart requirements, and Integration production readiness for operational preparation.
Integration errors
| Symptom | Check and recovery |
|---|---|
| HTTP 401 | Check registered active key, numeric kid, matching sub, aud:"anyflo:solver-hub", integer-second iat/exp, lifetime ≤900 seconds, clock synchronization, and signature. Mint a new JWT; use assisted recovery if no active key remains. |
| HTTP 403 or close 4002 | Confirm solver operational status with Anyflo. Reactivate first, then restart the stopped reference process. Rotation is not a status fix. |
| HTTP 409 registering a key | Choose a fresh unused positive kid; revoked IDs still conflict. |
| HTTP 404 revoking one key | Check that the key exists and is not already revoked; reconcile local rotation records. |
| HTTP 415 | Set a supported Content-Type for request bodies, separately from Accept. |
| HTTP 400 | Check JSON/CBOR syntax, exact body/schema, numeric IDs, and public-only Ed25519 JWK. Do not assume a uniform auth-error body. |
| HTTP 426 | Use a proper WebSocket upgrade with bearer authorization; ordinary HTTP GET is not a connection. |
Missing BidAck | Check strict fields (no solverId), complete deterministic payload signature, byte-valued sibling sig, CBOR/BigInt encoding, clock skew, replayed timestamp, quote expiry, bidding deadline, route eligibility, and connection liveness. The current WS path can silently drop invalid messages; there is no guaranteed error envelope. |
| Active snapshot failure | Preserve local state and retry reconciliation; do not replace a failed request with an empty order list. |
| Delivery uncertainty | Reconcile saved payout hash, assignment state, and chain/finality evidence. Never blindly resubmit a transaction. |
For process, wallet, RPC, funding, alerting, and safe payout recovery, use Reference Solver troubleshooting. Escalate with solver ID, environment, order ID, UTC time, relevant chain/transaction hashes, and sanitized logs—never JWTs, private JWKs, or wallet secrets.
