diff --git a/docs/index.html b/docs/index.html
index 3df0d9f..7341c90 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -38,9 +38,9 @@
"gasless": true,
"approveRequired": false
},
- "request": { "contentType": "application/json", "body": { "sentinelAddr": "^sent1[0-9a-z]{38}$", "country": "optional — ISO code or name, e.g. DE; validated before payment (COUNTRY_UNAVAILABLE if unmatched)" } },
+ "request": { "contentType": "application/json", "body": { "sentinelAddr": "^sent1[0-9a-z]{38}$", "country": "optional — ISO code or name, e.g. DE; validated before payment (COUNTRY_UNAVAILABLE if unmatched)", "protocol": "optional — 'v2ray' | 'wireguard'; pins the tunnel type so the selected node speaks it; validated before payment (INVALID_PROTOCOL / PROTOCOL_UNAVAILABLE if unmatched)" } },
"response": {
- "200": { "subscriptionId": "number", "planId": "number", "feeGranter": "sent1...", "nodeAddress": "sentnode1...", "nodeCountry": "string|null", "nodes": "string[]", "expiresAt": "ISO8601" },
+ "200": { "subscriptionId": "number", "planId": "number", "feeGranter": "sent1...", "nodeAddress": "sentnode1...", "nodeCountry": "string|null", "nodeProtocol": "'wireguard' | 'v2ray' | null — the selected node's actual protocol", "node": "{ address, online, protocol, country, city, moniker, peers, maxPeers, version, lastSeen } — selected node metadata", "nodes": "string[]", "expiresAt": "ISO8601 — paid window + 1-day fee-grant grace buffer", "expiresAtPaid": "ISO8601 — the paid window end" },
"402": "Sign EIP-3009 transferWithAuthorization, resend with PAYMENT-SIGNATURE header"
},
"packages": { "payment": "@x402/fetch", "scheme": "@x402/evm", "sentinel": "blue-js-sdk/ai-path" },
@@ -66,7 +66,7 @@
"/manifest": "Full JSON spec",
"/llms.txt": "Human + AI markdown summary",
"/pricing": "Human pricing table",
- "/nodes": "VPN nodes in operator plan with live geo data (country, city, protocol, online) + byCountry summary",
+ "/nodes": "VPN nodes in operator plan with live per-node metadata (address, online, protocol, country, city, moniker, peers, maxPeers, version, lastSeen) + byCountry & byProtocol summaries; filter with ?protocol=v2ray|wireguard and/or ?country=DE",
"/health": "Uptime",
"/agent/:sentinelAddr": "Subscription + fee-grant status"
}
@@ -2242,7 +2242,7 @@
Endpoints
Free endpoints (no payment)
GET /pricing Tiers, network, asset info
-GET /nodes Plan nodes + live geo (country, city, protocol, byCountry)
+GET /nodes Plan nodes + live metadata (?protocol= ?country=, byCountry, byProtocol)
GET /health Server status + uptime
GET /agent/:sentinelAddr Check subscription status
diff --git a/docs/llms.txt b/docs/llms.txt
index c2fadf5..f10508b 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -37,7 +37,7 @@ Paid (HTTP 402, body `{ "sentinelAddr": "sent1...", "country": "DE" }` — `coun
Free:
- GET `/manifest` — full machine-readable protocol spec (use this first, ~300 tokens)
- GET `/pricing` — human pricing table
-- GET `/nodes` — VPN nodes in operator plan with live geo data (country, city, protocol, online) and a `byCountry` summary
+- GET `/nodes` — VPN nodes in operator plan with live per-node metadata (address, online, protocol, country, city, moniker, peers, maxPeers, version, lastSeen) plus `byCountry` and `byProtocol` summaries. Filter with `?protocol=v2ray|wireguard` and/or `?country=DE`.
- GET `/health` — uptime
- GET `/agent/:sentinelAddr` — subscription + fee-grant status
@@ -51,13 +51,13 @@ Free:
## Agent flow
-1. POST to a paid endpoint with `{ sentinelAddr }` (add `country` to request a node location — validated before payment).
+1. POST to a paid endpoint with `{ sentinelAddr }` (add `country` to request a node location, and/or `protocol: "v2ray" | "wireguard"` to pin the tunnel type — both validated before payment, so an unavailable filter returns 400 without spending USDC).
2. Server returns 402 with `PAYMENT-REQUIRED` header (amount, asset, payTo, network).
3. Agent signs EIP-3009 locally (EIP-712).
4. Agent resends with `PAYMENT-SIGNATURE` header — `@x402/fetch` does this automatically.
5. Facilitator settles USDC on Base.
6. Server submits atomic `MsgShareSubscription` + `MsgGrantAllowance` on Sentinel.
-7. Server returns 200 `{ provisioned, subscriptionId, planId, feeGranter, nodeAddress, nodeCountry, nodes[], sentinelTxHash, expiresAt }`.
+7. Server returns 200 `{ provisioned, subscriptionId, planId, feeGranter, nodeAddress, nodeCountry, nodeProtocol, node, nodes[], sentinelTxHash, expiresAt, expiresAtPaid }`. `nodeProtocol` + `node` describe the actually-selected node (full metadata); `expiresAtPaid` is the paid window, while `expiresAt` includes a deliberate +1-day fee-grant grace buffer (the grant outlives the paid time so a session started at the very end can still pay gas).
8. Agent connects (all consume the same 200 fields):
- **All platforms (recommended):** `connect({ mnemonic, subscriptionId, feeGranter, nodeAddress, protocol: 'v2ray' })` — zero admin, zero manual install (the v2ray binary auto-downloads on Windows, macOS and Linux alike). Add `systemProxy: true` for whole-device routing. Omit `protocol` only if you have admin and want WireGuard nodes too. (See "JS-SDK connect".)
- **macOS/Linux native CLI (alternative — mainly for WireGuard or Node-less):** import the key (`printf '%s\n\n' "$MNEMONIC" | sentinel-dvpncli keys add agent --keyring.backend test`), `sentinel-dvpncli tx session-start --subscription-id --tx.fee-granter-addr --tx.from-name agent --keyring.backend test`, resolve the new id with `sentinel-dvpncli query sessions --account-addr --subscription-id --output-format json` (newest entry), then `sentinel-dvpncli connect `. (See "macOS & Linux — native CLI connect".)
@@ -133,6 +133,74 @@ const { data } = await axios.get('https://api.example.com/sensitive', {
}); // exits on vpn.ip
```
+## Two worked connect examples — no-root V2Ray, and Linux WireGuard full-tunnel
+
+These are the two paths agents actually hit. Both reuse the exact `provision` object from the
+payment block above (`{ subscriptionId, planId, feeGranter, nodeAddress, nodeProtocol, node }`).
+
+**Pin the protocol at provision time.** Pass `protocol` in the request body so x402 selects a node
+that actually speaks it and returns that node's real protocol — never a WireGuard node when you
+asked for userspace V2Ray. The 200 response echoes `nodeProtocol` (the selected node's true
+protocol) and a full `node` object (`address, online, protocol, country, city, moniker, peers,
+maxPeers, version, lastSeen`). You can also pre-shop with `GET /nodes?protocol=v2ray&country=DE`.
+
+### A. No-root V2Ray (Windows, macOS, Linux — zero admin)
+
+```typescript
+// Ask x402 for a V2Ray node up front. With protocol set, the server pre-validates availability
+// BEFORE you pay (400 PROTOCOL_UNAVAILABLE if none) — no USDC is spent on a guaranteed-fail request.
+const res = await paidFetch('https://x402.sentinel.co/vpn/connect/30days', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sentinelAddr: wallet.address, protocol: 'v2ray' }), // country optional
+});
+const provision = await res.json();
+// provision.nodeProtocol === 'v2ray', provision.node === the selected V2Ray node (full metadata).
+
+const vpn = await connect({
+ mnemonic: wallet.mnemonic,
+ subscriptionId: String(provision.subscriptionId),
+ feeGranter: provision.feeGranter,
+ nodeAddress: provision.nodeAddress, // the V2Ray node x402 already picked for you
+ protocol: 'v2ray', // userspace SOCKS5 — NO admin, NO manual install, any OS
+});
+// vpn => { connected, ip, protocol: 'v2ray', sessionId, nodeAddress, socksPort }
+// Route per-request through vpn.socksPort with socks5h:// (DNS at the exit) — see the axios snippet above.
+```
+
+### B. Linux WireGuard full-tunnel (root) — pass splitIPs to route ALL traffic
+
+```typescript
+// WireGuard is kernel-level and needs root on Linux/macOS (sudo) / Administrator on Windows.
+// Ask for a wireguard node so the server picks one and returns protocol-correct instructions.
+const res = await paidFetch('https://x402.sentinel.co/vpn/connect/30days', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sentinelAddr: wallet.address, protocol: 'wireguard' }),
+});
+const provision = await res.json();
+// provision.nodeProtocol === 'wireguard'. provision.instructions spells out the splitIPs requirement.
+
+const vpn = await connect({
+ mnemonic: wallet.mnemonic,
+ subscriptionId: String(provision.subscriptionId),
+ feeGranter: provision.feeGranter,
+ nodeAddress: provision.nodeAddress,
+ protocol: 'wireguard',
+ // REQUIRED on Linux for a real FULL tunnel. Without an explicit AllowedIPs, the Linux
+ // wg-quick path can fail to install a default route (and a stale 'wgsent0' from a crashed
+ // run blocks re-creation). Passing the catch-all routes forces all IPv4+IPv6 through the node:
+ splitIPs: ['0.0.0.0/0', '::/0'],
+});
+// vpn => { connected, ip, protocol: 'wireguard', sessionId, nodeAddress }
+// vpn.ip is the node exit IP — every connection on the box now egresses through it (full tunnel).
+//
+// Run this with sudo (it creates the wgsent0 interface). If a prior run crashed and left the
+// interface behind, tear it down first: sudo wg-quick down wgsent0 (ignore "does not exist").
+// SDK note: the duplicate-interface / cleanup edge on Linux full-tunnel is tracked in blue-js-sdk;
+// the splitIPs workaround above is the supported path until that fix ships.
+```
+
## Native CLI connect — the WireGuard / Node-less alternative (macOS, Linux, Windows)
The JS `connect()` above already works on macOS and Linux for V2Ray, so you usually do NOT need
diff --git a/server/src/index.ts b/server/src/index.ts
index 3e09d4e..979f505 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -4,7 +4,7 @@ import { paymentMiddleware, x402ResourceServer } from '@x402/express';
import { ExactEvmScheme } from '@x402/evm/exact/server';
import { createFacilitatorConfig } from '@coinbase/x402';
import { HTTPFacilitatorClient, type FacilitatorConfig } from '@x402/core/server';
-import { initSentinel, provisionAgent, checkAgentStatus, checkProvisioningCapacity, getEnrichedPlanNodes, matchNodesByCountry } from './sentinel.js';
+import { initSentinel, provisionAgent, checkAgentStatus, checkProvisioningCapacity, getEnrichedPlanNodes, matchNodesByCountry, matchNodesByProtocol } from './sentinel.js';
import { createSelfHostedFacilitator, startFacilitatorServer } from './facilitator.js';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -152,6 +152,44 @@ app.use(async (req, res, next) => {
}
}
+ // Protocol preference — WireGuard needs root/Administrator; V2Ray is userspace
+ // (no admin). An agent that can't elevate privileges MUST be able to force a
+ // V2Ray node. Validate the value and, if a protocol is requested that no
+ // online plan node currently speaks, reject BEFORE payment. Fails open if node
+ // enrichment errors (provisioning then picks the best available node and the
+ // response carries the node's real protocol so the agent can still branch).
+ const protocol = body.protocol;
+ if (protocol !== undefined) {
+ if (protocol !== 'wireguard' && protocol !== 'v2ray') {
+ return res.status(400).json({
+ code: 'INVALID_PROTOCOL',
+ message: "`protocol` must be 'v2ray' (userspace SOCKS5, no admin) or 'wireguard' (needs root/Administrator).",
+ nextAction: "Resend with protocol: 'v2ray' for no-admin connect, 'wireguard' for a full OS tunnel, or omit it to let the server pick. GET /nodes?protocol=v2ray lists no-admin nodes.",
+ docs: '/nodes',
+ });
+ }
+ try {
+ const enriched = await getEnrichedPlanNodes();
+ // Honour a co-requested country: only count nodes that satisfy BOTH.
+ const base = typeof country === 'string' && country.trim().length > 0
+ ? matchNodesByCountry(enriched, country)
+ : enriched;
+ const matches = matchNodesByProtocol(base, protocol);
+ if (matches.length === 0) {
+ const availableProtocols = [...new Set(enriched.filter(n => n.online && n.protocol).map(n => n.protocol))];
+ return res.status(400).json({
+ code: 'PROTOCOL_UNAVAILABLE',
+ message: `No online node in this plan currently speaks "${protocol}"${typeof country === 'string' ? ` in "${country}"` : ''}. You have NOT been charged — this check runs before payment.`,
+ availableProtocols,
+ nextAction: 'Resend with one of availableProtocols, drop the country constraint, or omit `protocol`. GET /nodes shows each node\'s live protocol.',
+ docs: '/nodes',
+ });
+ }
+ } catch (err) {
+ console.warn('[x402] Protocol pre-check errored (continuing):', (err as Error).message);
+ }
+ }
+
// Capacity check — if the operator provably cannot provision (subscription
// pool full + spendable P2P below the new-subscription cost), reject before
// the agent is asked to pay. Cached 60s in sentinel.ts; fails open on chain
@@ -401,6 +439,12 @@ app.get('/manifest', (_req, res) => {
description: 'Preferred node country — ISO 3166-1 alpha-2 code ("DE") or name ("Germany"). Validated BEFORE payment: if no online plan node matches, you get COUNTRY_UNAVAILABLE with availableCountries and are not charged. Omit to let the server pick.',
example: 'DE',
},
+ protocol: {
+ type: "string ('v2ray' | 'wireguard')",
+ required: false,
+ description: "Force the tunnel protocol of the selected node. 'v2ray' = userspace SOCKS5, NO admin/root needed (use this if you cannot elevate privileges). 'wireguard' = full OS network interface, needs root (Linux/macOS) or Administrator (Windows). Validated BEFORE payment: if no online plan node speaks it (optionally within the requested country), you get PROTOCOL_UNAVAILABLE with availableProtocols and are not charged. Omit to let the server pick; the response's nodeProtocol always tells you what you actually got.",
+ example: 'v2ray',
+ },
},
},
response: {
@@ -412,10 +456,13 @@ app.get('/manifest', (_req, res) => {
planId: 'number',
feeGranter: 'string (sent1...)',
nodeAddress: 'string (sentnode1...)',
- nodeCountry: 'string | null — country of nodeAddress when a country was requested and matched',
- nodes: 'string[]',
+ nodeCountry: 'string | null — country of the selected node (verified from its live status)',
+ nodeProtocol: "'wireguard' | 'v2ray' | null — the ACTUAL protocol of nodeAddress. Use this to decide whether you need root/Administrator (wireguard) or not (v2ray). null only if the node could not be reached to verify.",
+ node: 'object | null — full metadata for nodeAddress: { address, online, country, countryCode, city, protocol, moniker, peers, maxPeers, version, lastSeen }. null if no online node could be verified.',
+ nodes: 'string[] — all plan node addresses (fallback pool)',
sentinelTxHash: 'string',
- expiresAt: 'string (ISO 8601)',
+ expiresAt: 'string (ISO 8601) — fee-grant expiry; this is days + 1 day grace buffer so a session started near the end still has gas to start and cancel cleanly',
+ expiresAtPaid: 'string (ISO 8601) — end of the whole-day window you paid for (1/7/30 days). expiresAt is intentionally ~1 day later (grace buffer), not a bug.',
},
},
paymentRequired: {
@@ -430,6 +477,8 @@ app.get('/manifest', (_req, res) => {
INVALID_SENTINEL_ADDR: { status: 400, meaning: "sentinelAddr is not a valid sent1-bech32 address — use createWallet() from 'blue-js-sdk/ai-path'." },
UNKNOWN_TIER: { status: 404, meaning: 'Path tier is not one of 1day | 7days | 30days.' },
COUNTRY_UNAVAILABLE: { status: 400, meaning: 'Requested country has no online plan node. Returned BEFORE payment — no USDC charged. Response includes availableCountries; resend with one of those or omit country.' },
+ INVALID_PROTOCOL: { status: 400, meaning: "`protocol` body field must be 'v2ray' or 'wireguard'. Returned BEFORE payment — no USDC charged." },
+ PROTOCOL_UNAVAILABLE: { status: 400, meaning: 'Requested protocol (optionally + country) has no online plan node. Returned BEFORE payment — no USDC charged. Response includes availableProtocols; resend with one of those or omit protocol.' },
PAYMENT_REQUIRED: { status: 402, meaning: 'Sign EIP-3009 transferWithAuthorization and resend with PAYMENT-SIGNATURE header. @x402/fetch handles this automatically.' },
CAPACITY_EXHAUSTED: { status: 503, meaning: 'Operator cannot provision right now (subscription pool full and operator balance below new-subscription cost). Returned BEFORE payment — no USDC charged. Retry later; re-checked every 60s.' },
PROVISIONING_FAILED: { status: 500, meaning: 'Sentinel TX failed after payment verification. USDC was NOT charged — settlement only happens after a successful 2xx response. Safe to retry.' },
@@ -610,23 +659,52 @@ app.get('/llms.txt', (_req, res) => {
res.type('text/plain').send(llmsTxtCache);
});
-// Node list — free, enriched with live geo data from each node's /status
-// endpoint so agents can see WHERE each node is and choose (or request a
-// country in the POST body and let the server pick a matching node).
-app.get('/nodes', async (_req, res) => {
+// Node list — free, enriched with live status from each node's root endpoint so
+// agents can see WHERE each node is and WHICH protocol it speaks. Optional query
+// filters: ?protocol=v2ray|wireguard and ?country=DE (ISO code or name). Both
+// the POST connect body and these filters share the same matching logic.
+app.get('/nodes', async (req, res) => {
try {
- const nodes = await getEnrichedPlanNodes();
+ let nodes = await getEnrichedPlanNodes();
+ const total = nodes.length;
+
+ const protocolQuery = typeof req.query.protocol === 'string' ? req.query.protocol : undefined;
+ if (protocolQuery !== undefined) {
+ if (protocolQuery !== 'wireguard' && protocolQuery !== 'v2ray') {
+ return res.status(400).json({
+ code: 'INVALID_PROTOCOL',
+ message: "Query `protocol` must be 'v2ray' or 'wireguard'.",
+ docs: '/nodes',
+ });
+ }
+ nodes = matchNodesByProtocol(nodes, protocolQuery);
+ }
+
+ const countryQuery = typeof req.query.country === 'string' && req.query.country.trim().length > 0
+ ? req.query.country.trim()
+ : undefined;
+ if (countryQuery !== undefined) {
+ nodes = matchNodesByCountry(nodes, countryQuery);
+ }
+
const byCountry: Record = {};
+ const byProtocol: Record = {};
for (const n of nodes) {
if (n.online && n.country) byCountry[n.country] = (byCountry[n.country] || 0) + 1;
+ if (n.online && n.protocol) byProtocol[n.protocol] = (byProtocol[n.protocol] || 0) + 1;
}
+
res.json({
planId: parseInt(process.env.SENTINEL_PLAN_ID || '42', 10),
+ // count reflects the (optionally filtered) result; total is the plan size.
count: nodes.length,
+ total,
online: nodes.filter(n => n.online).length,
+ filters: { protocol: protocolQuery ?? null, country: countryQuery ?? null },
byCountry,
+ byProtocol,
nodes,
- note: 'Pass { country: "DE" } (ISO code or name) in the POST body to get a node in that country, or pass a specific nodeAddress from this list to connect(). Omit both and the server picks a random node.',
+ note: "Filter live: GET /nodes?protocol=v2ray (no-admin nodes) or ?country=DE. To connect, POST { sentinelAddr, protocol?, country? } and the server picks a matching node, or pass a specific nodeAddress from this list to connect(). protocol:'v2ray' needs no admin; 'wireguard' needs root/Administrator.",
});
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -683,8 +761,13 @@ async function provisionVpn(days: number, body: Record) {
? body.country.trim()
: undefined;
- console.log(`[x402] Payment verified. Provisioning ${days} days for ${sentinelAddr}${country ? ` (country: ${country})` : ''}... (USDC settles only after a 2xx response)`);
- const result = await provisionAgent(sentinelAddr, days, country);
+ // Pre-payment middleware already validated this is 'v2ray' | 'wireguard' | absent.
+ const protocol = body.protocol === 'wireguard' || body.protocol === 'v2ray'
+ ? body.protocol
+ : undefined;
+
+ console.log(`[x402] Payment verified. Provisioning ${days} days for ${sentinelAddr}${country ? ` (country: ${country})` : ''}${protocol ? ` (protocol: ${protocol})` : ''}... (USDC settles only after a 2xx response)`);
+ const result = await provisionAgent(sentinelAddr, days, country, protocol);
return result;
}
diff --git a/server/src/sentinel.ts b/server/src/sentinel.ts
index 53d0437..e9f5b14 100644
--- a/server/src/sentinel.ts
+++ b/server/src/sentinel.ts
@@ -433,46 +433,87 @@ export interface ProvisionResult {
feeGranter: string;
nodeAddress: string;
nodeCountry: string | null;
+ // The ACTUAL protocol of the selected node, verified from its live status —
+ // so the agent never gets a v2ray connect snippet for a wireguard node.
+ // null when the node could not be reached for verification.
+ nodeProtocol: 'wireguard' | 'v2ray' | null;
+ // Full metadata for the recommended node (moniker/city/peers/version/...),
+ // so the agent can confirm what it's connecting to without a second round-trip.
+ node: EnrichedNode | null;
nodes: string[];
sentinelTxHash: string;
expiresAt: string;
+ // Whole-day window the agent paid for (1/7/30).
+ expiresAtPaid: string;
operatorAddress: string;
instructions: string;
}
-// Builds the provision response. When the agent requested a country, pick a
-// node verified (via its /status endpoint) to be in that country; otherwise —
-// or if no match is online — fall back to a random plan node. The recommended
-// node MUST come from the plan: the shared subscription only works with plan
-// nodes, so global SDK country discovery would hand the agent a dead session.
+// Connect snippet tailored to the SELECTED node's real protocol. V2Ray is a
+// userspace SOCKS5 tunnel (no admin); WireGuard needs an OS network interface
+// (root on Linux/macOS, Administrator on Windows). Emitting the wrong one was
+// the live failure: a v2ray snippet handed to an agent that got a wireguard node.
+function buildInstructions(protocol: 'wireguard' | 'v2ray' | null, opts: {
+ nodeAddress: string; subscriptionId: number; feeGranter: string;
+}): string {
+ const base = `import { connect } from 'blue-js-sdk/ai-path'; await connect({ mnemonic, protocol: '${protocol || 'v2ray'}', nodeAddress: '${opts.nodeAddress}', subscriptionId: '${opts.subscriptionId}', feeGranter: '${opts.feeGranter}' });`;
+ if (protocol === 'wireguard') {
+ return `${base} // WireGuard node: needs root (Linux/macOS: sudo) or Administrator (Windows). Linux full-tunnel: pass splitIPs: ['0.0.0.0/0','::/0']. gas paid by feeGranter.`;
+ }
+ // v2ray (or unknown — v2ray is the safe no-admin default)
+ return `${base} // protocol:'v2ray' = userspace SOCKS5, NO admin needed; binary auto-installs. gas paid by feeGranter.`;
+}
+
+// Builds the provision response. The recommended node MUST come from the plan:
+// the shared subscription only works with plan nodes, so global SDK discovery
+// would hand the agent a dead session. We pick the best ONLINE plan node that
+// satisfies the agent's preferences — protocol first (a wrong protocol breaks
+// the connect outright), then country — and return that node's VERIFIED protocol
+// plus its full metadata so the connect instructions always match reality.
async function buildProvisionResult(opts: {
sentinelAddr: string;
days: number;
subscriptionId: number;
txHash: string;
expiresAt: string;
+ expiresAtPaid: string;
country?: string;
+ protocol?: 'wireguard' | 'v2ray';
}): Promise {
const planNodes = await getPlanNodes();
- let recommended = '';
- let nodeCountry: string | null = null;
+ let pick: EnrichedNode | null = null;
- if (opts.country) {
- try {
- const matches = matchNodesByCountry(await getEnrichedPlanNodes(), opts.country);
- if (matches.length > 0) {
- const pick = matches[Math.floor(Math.random() * matches.length)];
- recommended = pick.address;
- nodeCountry = pick.country;
- } else {
- console.warn(`[sentinel] No online plan node matches country "${opts.country}" — falling back to random node`);
- }
- } catch (err) {
- console.warn('[sentinel] Country-aware node pick failed, falling back to random:', (err as Error).message);
+ try {
+ const enriched = await getEnrichedPlanNodes();
+ // Start from online nodes, then narrow by the requested protocol and country.
+ // Each filter is only applied if it leaves at least one candidate, so a
+ // preference never produces an empty pick when a looser match exists.
+ let candidates = enriched.filter(n => n.online);
+
+ if (opts.protocol) {
+ const byProtocol = candidates.filter(n => n.protocol === opts.protocol);
+ if (byProtocol.length > 0) candidates = byProtocol;
+ else console.warn(`[sentinel] No online plan node speaks "${opts.protocol}" — ignoring protocol preference`);
+ }
+
+ if (opts.country) {
+ const byCountry = matchNodesByCountry(candidates, opts.country);
+ if (byCountry.length > 0) candidates = byCountry;
+ else console.warn(`[sentinel] No online plan node matches country "${opts.country}" — ignoring country preference`);
+ }
+
+ if (candidates.length > 0) {
+ pick = candidates[Math.floor(Math.random() * candidates.length)];
+ } else {
+ console.warn('[sentinel] No online plan nodes available to recommend — falling back to a raw plan node');
}
+ } catch (err) {
+ console.warn('[sentinel] Enriched node pick failed, falling back to random:', (err as Error).message);
}
- if (!recommended) recommended = pickRandomNode(planNodes) || '';
+ // Fall back to a raw (unverified) plan node only if enrichment found nothing.
+ const recommended = pick?.address || pickRandomNode(planNodes) || '';
+ const nodeProtocol = pick?.protocol ?? null;
return {
provisioned: true,
@@ -482,15 +523,29 @@ async function buildProvisionResult(opts: {
planId: PLAN_ID,
feeGranter: operatorAddress,
nodeAddress: recommended,
- nodeCountry,
+ nodeCountry: pick?.country ?? null,
+ nodeProtocol,
+ node: pick,
nodes: planNodes.map(n => n.address),
sentinelTxHash: opts.txHash,
expiresAt: opts.expiresAt,
+ expiresAtPaid: opts.expiresAtPaid,
operatorAddress,
- instructions: `import { connect } from 'blue-js-sdk/ai-path'; await connect({ mnemonic, protocol: 'v2ray', nodeAddress: '${recommended}', subscriptionId: '${opts.subscriptionId}', feeGranter: '${operatorAddress}' }); // protocol:'v2ray' = no admin; binary auto-installs. gas paid by feeGranter.`,
+ instructions: buildInstructions(nodeProtocol, {
+ nodeAddress: recommended,
+ subscriptionId: opts.subscriptionId,
+ feeGranter: operatorAddress,
+ }),
};
}
+// Accepts a node-list and a protocol; returns only online nodes speaking it.
+export function matchNodesByProtocol(
+ nodes: EnrichedNode[], protocol: 'wireguard' | 'v2ray',
+): EnrichedNode[] {
+ return nodes.filter(n => n.online && n.protocol === protocol);
+}
+
// MsgGrantAllowance hard-fails when the grantee already holds a fee grant
// (a re-paying agent), and atomicity takes MsgShareSubscription down with it —
// observed live 2026-06-12 as a 500 PROVISIONING_FAILED on every repeat
@@ -537,6 +592,7 @@ export async function provisionAgent(
sentinelAddr: string,
days: number,
country?: string,
+ protocol?: 'wireguard' | 'v2ray',
): Promise {
if (!safeBroadcast) {
throw new Error('Sentinel not initialized — call initSentinel() first');
@@ -546,7 +602,14 @@ export async function provisionAgent(
throw new Error('Invalid Sentinel address — must start with sent1');
}
- const expirationDate = new Date(Date.now() + days * 86_400_000 + 86_400_000);
+ // The fee grant gets a deliberate +1 day grace buffer beyond the paid window,
+ // so a session started near the end of the paid period still has gas to start
+ // and cleanly cancel. `expiresAtPaid` is the whole-day window the agent paid
+ // for; `expiresAt` is the (longer) fee-grant expiry. Both are returned so the
+ // agent sees the real numbers and the ~Nd+1 grant is not mistaken for a bug.
+ const now = Date.now();
+ const paidExpirationDate = new Date(now + days * 86_400_000);
+ const expirationDate = new Date(now + days * 86_400_000 + 86_400_000);
const feeGrantMsg = buildFeeGrantMsg(operatorAddress, sentinelAddr, {
spendLimit: FEE_GRANT_SPEND_LIMIT,
expiration: expirationDate,
@@ -580,7 +643,9 @@ export async function provisionAgent(
subscriptionId: slot.id,
txHash: result.transactionHash,
expiresAt: expirationDate.toISOString(),
+ expiresAtPaid: paidExpirationDate.toISOString(),
country,
+ protocol,
});
}
@@ -631,7 +696,9 @@ export async function provisionAgent(
subscriptionId,
txHash: result.transactionHash,
expiresAt: expirationDate.toISOString(),
+ expiresAtPaid: paidExpirationDate.toISOString(),
country,
+ protocol,
});
}
@@ -694,22 +761,40 @@ export interface EnrichedNode {
remote_addrs: string[];
online: boolean;
country: string | null;
+ countryCode: string | null;
city: string | null;
protocol: 'wireguard' | 'v2ray' | null;
moniker: string | null;
peers: number | null;
maxPeers: number | null;
+ version: string | null;
+ // ISO timestamp of the last successful status probe (null if never reached).
+ lastSeen: string | null;
}
const STATUS_PROBE_TIMEOUT = 7_000;
+// LCD/RPC v3 returns remote_addrs as bare "IP:PORT" or "host:PORT" strings with
+// NO scheme. `new URL("1.2.3.4:8585")` throws (treated as scheme) and
+// `new URL("host:8585")` misparses "host:" as the scheme with an empty hostname —
+// which is exactly why every probe used to return null and /nodes reported 0
+// online across the whole plan. Prefix https:// when there's no scheme, matching
+// blue-js-sdk's resolveNodeUrl(). IPv6 forms already arrive bracketed
+// ("[::1]:8585") so URL parses them once a scheme is present.
+function normalizeRemoteAddrToUrl(remoteAddr: string): URL | null {
+ const withScheme = /^https?:\/\//i.test(remoteAddr) ? remoteAddr : `https://${remoteAddr}`;
+ try {
+ return new URL(withScheme);
+ } catch (err) {
+ console.warn(`[sentinel] Unparseable remote_addr "${remoteAddr}":`, (err as Error).message);
+ return null;
+ }
+}
+
function probeNodeStatus(remoteAddr: string): Promise | null> {
return new Promise((resolve) => {
- let url: URL;
- try {
- url = new URL(remoteAddr);
- } catch (err) {
- console.warn(`[sentinel] Unparseable remote_addr "${remoteAddr}":`, (err as Error).message);
+ const url = normalizeRemoteAddrToUrl(remoteAddr);
+ if (!url) {
resolve(null);
return;
}
@@ -718,7 +803,9 @@ function probeNodeStatus(remoteAddr: string): Promise | null
{
hostname: url.hostname,
port: url.port || 443,
- path: '/status',
+ // v3 dVPN nodes serve their status JSON at the ROOT path ("/"), not
+ // "/status" — the old "/status" probe 404'd on every node.
+ path: '/',
method: 'GET',
rejectUnauthorized: false, // dVPN nodes use self-signed certs by design
timeout: STATUS_PROBE_TIMEOUT,
@@ -731,7 +818,7 @@ function probeNodeStatus(remoteAddr: string): Promise | null
const json = JSON.parse(body);
resolve(json.result || json);
} catch (err) {
- console.warn(`[sentinel] Node ${url.hostname} returned non-JSON /status:`, (err as Error).message);
+ console.warn(`[sentinel] Node ${url.hostname} returned non-JSON status:`, (err as Error).message);
resolve(null);
}
});
@@ -755,19 +842,37 @@ export async function getEnrichedPlanNodes(): Promise {
return enrichedCache;
}
+ const probedAt = new Date().toISOString();
const nodes = await getPlanNodes();
const enriched = await Promise.all(nodes.map(async (n): Promise => {
- const status = n.remote_addrs[0] ? await probeNodeStatus(n.remote_addrs[0]) : null;
+ // Probe every advertised address, not just the first — a node may publish a
+ // dead IPv6 ahead of a live IPv4 (or vice versa); the first reachable one wins.
+ let status: Record | null = null;
+ for (const addr of n.remote_addrs) {
+ status = await probeNodeStatus(addr);
+ if (status) break;
+ }
+ // v3 /status reports protocol as the string `service_type`
+ // ("wireguard" | "v2ray"), NOT the numeric `type` the old code checked —
+ // which is why protocol was null for every node even when reachable.
+ const serviceType = status?.service_type;
+ const protocol: 'wireguard' | 'v2ray' | null =
+ serviceType === 'wireguard' ? 'wireguard' : serviceType === 'v2ray' ? 'v2ray' : null;
return {
address: n.address,
remote_addrs: n.remote_addrs,
online: status !== null,
country: status?.location?.country ?? null,
+ countryCode: status?.location?.country_code ?? null,
city: status?.location?.city ?? null,
- protocol: status?.type === 1 ? 'wireguard' : status?.type === 2 ? 'v2ray' : null,
+ protocol,
moniker: status?.moniker ?? null,
peers: typeof status?.peers === 'number' ? status.peers : null,
- maxPeers: typeof status?.max_peers === 'number' ? status.max_peers : null,
+ // v3 status does not currently expose a peer cap; surface qos.max_peers
+ // if a node ever reports it, else null.
+ maxPeers: typeof status?.qos?.max_peers === 'number' ? status.qos.max_peers : null,
+ version: status?.version?.tag ?? null,
+ lastSeen: status !== null ? probedAt : null,
};
}));