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, }; }));