blue-js-sdk 2.4.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/app-helpers.js +55 -0
- package/chain/broadcast.js +27 -0
- package/chain/fee-grants.js +271 -5
- package/chain/index.js +8 -2
- package/chain/queries.js +177 -3
- package/chain/rpc.js +117 -4
- package/cli.js +26 -5
- package/client.js +79 -7
- package/connection/connect.js +119 -21
- package/connection/disconnect.js +93 -12
- package/connection/index.js +2 -0
- package/connection/logger.js +66 -0
- package/connection/resilience.js +12 -7
- package/connection/state.js +21 -12
- package/connection/tunnel.js +24 -8
- package/cosmjs-setup.js +68 -2
- package/docs/PRIVY-INTEGRATION.md +177 -0
- package/errors.js +167 -0
- package/index.js +75 -2
- package/node-connect.js +190 -50
- package/operator.js +26 -0
- package/package.json +11 -11
- package/session-manager.js +68 -0
- package/speedtest.js +139 -0
- package/test-all-logic.js +8 -6
- package/test-e2e.js +138 -0
- package/test-mainnet.js +2 -2
- package/test-plan-connect-e2e.js +235 -0
- package/test-subscription-flows.js +14 -4
- package/types/connection.d.ts +6 -2
- package/types/index.d.ts +2 -2
- package/ai-path/ADMIN-ELEVATION.md +0 -116
- package/ai-path/AI-MANIFESTO.md +0 -185
- package/ai-path/BREAKING.md +0 -74
- package/ai-path/CHECKLIST.md +0 -619
- package/ai-path/CONNECTION-STEPS.md +0 -724
- package/ai-path/DECISION-TREE.md +0 -422
- package/ai-path/DEPENDENCIES.md +0 -459
- package/ai-path/E2E-FLOW.md +0 -1707
- package/ai-path/FAILURES.md +0 -410
- package/ai-path/GUIDE.md +0 -1315
- package/ai-path/README.md +0 -599
- package/ai-path/SPLIT-TUNNEL.md +0 -266
- package/ai-path/cli.js +0 -548
- package/ai-path/connect.js +0 -1028
- package/ai-path/discover.js +0 -178
- package/ai-path/environment.js +0 -266
- package/ai-path/errors.js +0 -86
- package/ai-path/examples/autonomous-agent.mjs +0 -220
- package/ai-path/examples/multi-region.mjs +0 -174
- package/ai-path/examples/one-shot.mjs +0 -31
- package/ai-path/index.js +0 -79
- package/ai-path/pricing.js +0 -137
- package/ai-path/recommend.js +0 -413
- package/ai-path/run-admin.vbs +0 -25
- package/ai-path/setup.js +0 -291
- package/ai-path/wallet.js +0 -137
package/speedtest.js
CHANGED
|
@@ -565,3 +565,142 @@ export function compareSpeedTests(before, after) {
|
|
|
565
565
|
};
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
// ─── Post-Tunnel Google Accessibility Checks ────────────────────────────────
|
|
569
|
+
//
|
|
570
|
+
// Some nodes route Cloudflare fine but block Google (region-specific egress
|
|
571
|
+
// filtering). Speedtest passing != general internet works. These helpers do
|
|
572
|
+
// a cheap, latency-only HTTPS hit against `google.com` after the tunnel is
|
|
573
|
+
// up — useful as a fast pre-flight before a full speedtest, or as a separate
|
|
574
|
+
// "general internet works" signal in audit results.
|
|
575
|
+
//
|
|
576
|
+
// Two flavors:
|
|
577
|
+
// - checkGoogleDirect: for tunnels where all traffic is tunneled (WireGuard)
|
|
578
|
+
// - checkGoogleViaSocks5: for tunnels where traffic goes via local SOCKS5 (V2Ray)
|
|
579
|
+
//
|
|
580
|
+
// Direct check uses an external resolver (8.8.8.8 / 1.1.1.1) to resolve
|
|
581
|
+
// `www.google.com` BEFORE the tunnel may have working DNS, then issues HTTPS
|
|
582
|
+
// to the IP with `Host: www.google.com` + SNI. Works on tunnels that don't
|
|
583
|
+
// route DNS or that point to dead resolvers.
|
|
584
|
+
|
|
585
|
+
const GOOGLE_HOST = 'www.google.com';
|
|
586
|
+
const GOOGLE_DNS_CACHE_TTL = 5 * 60_000;
|
|
587
|
+
let cachedGoogleIp = null;
|
|
588
|
+
let cachedGoogleTime = 0;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Resolve `www.google.com` to an A record using public resolvers, with
|
|
592
|
+
* fallback to the system resolver and finally `dns.lookup`. Result is cached
|
|
593
|
+
* for {@link GOOGLE_DNS_CACHE_TTL} ms across calls in the same process.
|
|
594
|
+
*
|
|
595
|
+
* @returns {Promise<string|null>} IPv4 address or null if every resolver failed
|
|
596
|
+
*/
|
|
597
|
+
export async function resolveGoogleIp() {
|
|
598
|
+
if (cachedGoogleIp && Date.now() - cachedGoogleTime < GOOGLE_DNS_CACHE_TTL) return cachedGoogleIp;
|
|
599
|
+
try {
|
|
600
|
+
const resolver = new dns.Resolver();
|
|
601
|
+
resolver.setServers(['8.8.8.8', '1.1.1.1']);
|
|
602
|
+
const addrs = await new Promise((resolve, reject) => {
|
|
603
|
+
resolver.resolve4(GOOGLE_HOST, (err, addresses) => err ? reject(err) : resolve(addresses));
|
|
604
|
+
});
|
|
605
|
+
if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
|
|
606
|
+
} catch { }
|
|
607
|
+
try {
|
|
608
|
+
const addrs = await dns.promises.resolve4(GOOGLE_HOST);
|
|
609
|
+
if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
|
|
610
|
+
} catch { }
|
|
611
|
+
try {
|
|
612
|
+
const { address } = await dns.promises.lookup(GOOGLE_HOST);
|
|
613
|
+
cachedGoogleIp = address;
|
|
614
|
+
cachedGoogleTime = Date.now();
|
|
615
|
+
return cachedGoogleIp;
|
|
616
|
+
} catch { }
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Check if `google.com` is reachable through the ambient network path
|
|
622
|
+
* (typically a WireGuard tunnel where all traffic is tunneled). Tries the
|
|
623
|
+
* resolved IP first (with `Host` header + SNI), then falls back to the
|
|
624
|
+
* hostname directly. Any successful TLS handshake counts as reachable.
|
|
625
|
+
*
|
|
626
|
+
* @param {number} [timeoutMs=10000]
|
|
627
|
+
* @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
|
|
628
|
+
*/
|
|
629
|
+
export async function checkGoogleDirect(timeoutMs = 10_000) {
|
|
630
|
+
const start = Date.now();
|
|
631
|
+
const targetIp = await resolveGoogleIp();
|
|
632
|
+
const targets = [];
|
|
633
|
+
if (targetIp) targets.push(`https://${targetIp}/`);
|
|
634
|
+
targets.push(`https://${GOOGLE_HOST}/`);
|
|
635
|
+
|
|
636
|
+
for (const url of targets) {
|
|
637
|
+
try {
|
|
638
|
+
await new Promise((resolve, reject) => {
|
|
639
|
+
const parsed = new URL(url);
|
|
640
|
+
const options = {
|
|
641
|
+
hostname: parsed.hostname,
|
|
642
|
+
path: '/',
|
|
643
|
+
method: 'GET',
|
|
644
|
+
rejectUnauthorized: false,
|
|
645
|
+
agent: false,
|
|
646
|
+
headers: { Host: GOOGLE_HOST },
|
|
647
|
+
servername: GOOGLE_HOST,
|
|
648
|
+
};
|
|
649
|
+
const req = https.get(options, (res) => {
|
|
650
|
+
res.destroy();
|
|
651
|
+
resolve();
|
|
652
|
+
});
|
|
653
|
+
req.on('error', reject);
|
|
654
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error('timeout')); });
|
|
655
|
+
});
|
|
656
|
+
return {
|
|
657
|
+
googleAccessible: true,
|
|
658
|
+
googleLatencyMs: Date.now() - start,
|
|
659
|
+
googleError: null,
|
|
660
|
+
};
|
|
661
|
+
} catch { }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
googleAccessible: false,
|
|
666
|
+
googleLatencyMs: null,
|
|
667
|
+
googleError: 'Google unreachable through tunnel',
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Check if `google.com` is reachable through a V2Ray SOCKS5 proxy on
|
|
673
|
+
* localhost. Required because native Node `fetch` silently ignores SOCKS
|
|
674
|
+
* proxy agents — must use axios + SocksProxyAgent.
|
|
675
|
+
*
|
|
676
|
+
* @param {number} proxyPort - Local V2Ray SOCKS5 port (e.g. 1080)
|
|
677
|
+
* @param {number} [timeoutMs=10000]
|
|
678
|
+
* @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
|
|
679
|
+
*/
|
|
680
|
+
export async function checkGoogleViaSocks5(proxyPort, timeoutMs = 10_000) {
|
|
681
|
+
const start = Date.now();
|
|
682
|
+
const agent = new SocksProxyAgent(`socks5://127.0.0.1:${proxyPort}`);
|
|
683
|
+
try {
|
|
684
|
+
await axios.get(`https://${GOOGLE_HOST}/`, {
|
|
685
|
+
timeout: timeoutMs,
|
|
686
|
+
httpAgent: agent,
|
|
687
|
+
httpsAgent: agent,
|
|
688
|
+
maxRedirects: 2,
|
|
689
|
+
validateStatus: () => true,
|
|
690
|
+
});
|
|
691
|
+
return {
|
|
692
|
+
googleAccessible: true,
|
|
693
|
+
googleLatencyMs: Date.now() - start,
|
|
694
|
+
googleError: null,
|
|
695
|
+
};
|
|
696
|
+
} catch (err) {
|
|
697
|
+
return {
|
|
698
|
+
googleAccessible: false,
|
|
699
|
+
googleLatencyMs: null,
|
|
700
|
+
googleError: err.message || 'Google unreachable through SOCKS5',
|
|
701
|
+
};
|
|
702
|
+
} finally {
|
|
703
|
+
agent.destroy();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
package/test-all-logic.js
CHANGED
|
@@ -29,12 +29,14 @@ t('formatUptime 0', sdk.formatUptime(0) === '0s');
|
|
|
29
29
|
|
|
30
30
|
// ═══ ADDRESS CONVERSION (6) ═══
|
|
31
31
|
console.log('═══ ADDRESS CONVERSION ═══');
|
|
32
|
-
|
|
32
|
+
// Valid sent1 address derived from BIP39 test mnemonic ("abandon abandon...about")
|
|
33
|
+
const ADDR = 'sent19rl4cm2hmr8afy4kldpxz3fka4jguq0a8mmym6';
|
|
34
|
+
t('shortAddress truncates', sdk.shortAddress(ADDR).includes('...'));
|
|
33
35
|
t('shortAddress short passthrough', sdk.shortAddress('sent1abc') === 'sent1abc');
|
|
34
|
-
t('sentToSentprov', sdk.sentToSentprov(
|
|
35
|
-
t('sentToSentnode', sdk.sentToSentnode(
|
|
36
|
-
t('sentprovToSent', sdk.sentprovToSent(sdk.sentToSentprov(
|
|
37
|
-
t('isSameKey cross-prefix', sdk.isSameKey(
|
|
36
|
+
t('sentToSentprov', sdk.sentToSentprov(ADDR).startsWith('sentprov'));
|
|
37
|
+
t('sentToSentnode', sdk.sentToSentnode(ADDR).startsWith('sentnode'));
|
|
38
|
+
t('sentprovToSent', sdk.sentprovToSent(sdk.sentToSentprov(ADDR)).startsWith('sent1'));
|
|
39
|
+
t('isSameKey cross-prefix', sdk.isSameKey(ADDR, sdk.sentToSentprov(ADDR)));
|
|
38
40
|
|
|
39
41
|
// ═══ VALIDATION (7) ═══
|
|
40
42
|
console.log('═══ VALIDATION ═══');
|
|
@@ -78,7 +80,7 @@ t('no duplicate DNS', new Set(sdk.resolveDnsServers().split(', ')).size === sdk.
|
|
|
78
80
|
|
|
79
81
|
// ═══ ERROR SYSTEM (20) ═══
|
|
80
82
|
console.log('═══ ERROR SYSTEM ═══');
|
|
81
|
-
t('
|
|
83
|
+
t('42 error codes', Object.values(sdk.ErrorCodes).length === 42);
|
|
82
84
|
t('all have messages', Object.values(sdk.ErrorCodes).every(c => sdk.userMessage(c) !== 'An unexpected error occurred.'));
|
|
83
85
|
t('unknown default', sdk.userMessage('FAKE') === 'An unexpected error occurred.');
|
|
84
86
|
t('INSUFFICIENT fatal', sdk.ERROR_SEVERITY[sdk.ErrorCodes.INSUFFICIENT_BALANCE] === 'fatal');
|
package/test-e2e.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SENTINEL SDK — FULL E2E TEST RUNNER
|
|
4
|
+
*
|
|
5
|
+
* Runs every test suite in order. All suites that need chain access use
|
|
6
|
+
* the MNEMONIC from ../ ai-path/.env or a local .env.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node test-e2e.js # run everything
|
|
10
|
+
* node test-e2e.js --offline # logic tests only (no network)
|
|
11
|
+
* node test-e2e.js --quick # offline + chain queries, no TX or connection
|
|
12
|
+
*
|
|
13
|
+
* Suites (in order):
|
|
14
|
+
* 1. Logic — 127 pure-logic tests, no network (test-all-logic.js)
|
|
15
|
+
* 2. FeeGrant E2E — isActiveStatus, error codes, queryFeeGrant offline + live (test-plan-connect-e2e.js)
|
|
16
|
+
* 3. Mainnet — wallet, queries, cache, preflight, WireGuard connect (test-mainnet.js)
|
|
17
|
+
* 4. Subscriptions — subscribe, share, feegrant, onboard, renew, cancel (test-subscription-flows.js)
|
|
18
|
+
*
|
|
19
|
+
* Suites 3-4 broadcast real TXs and cost ~1–3 P2P per run.
|
|
20
|
+
* Suite 3 opens and closes one WireGuard session.
|
|
21
|
+
* Never run suites in parallel — chain rate limits apply (7s between TXs).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
25
|
+
import { resolve, dirname } from 'path';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import { spawnSync } from 'child_process';
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
dotenvConfig({ path: resolve(__dirname, '../ai-path/.env') });
|
|
31
|
+
dotenvConfig(); // also try CWD .env
|
|
32
|
+
|
|
33
|
+
const MNEMONIC = process.env.MNEMONIC;
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
const offlineOnly = args.includes('--offline');
|
|
36
|
+
const quickMode = args.includes('--quick');
|
|
37
|
+
|
|
38
|
+
const FEE_GRANTER = 'sent1t0xjyflrah5n36rfkpfeuw6pz6vl2g27x2793l';
|
|
39
|
+
const FEE_GRANTEE = MNEMONIC ? undefined : null; // resolved from wallet
|
|
40
|
+
const PLAN_ID = '42';
|
|
41
|
+
|
|
42
|
+
console.log('═══════════════════════════════════════════════════════════');
|
|
43
|
+
console.log(' SENTINEL SDK — FULL E2E TEST RUNNER');
|
|
44
|
+
console.log('═══════════════════════════════════════════════════════════');
|
|
45
|
+
console.log(` Mode : ${offlineOnly ? 'offline-only' : quickMode ? 'quick (no TX)' : 'full (chain + TX)'}`);
|
|
46
|
+
console.log(` MNEMONIC : ${MNEMONIC ? 'set' : 'NOT SET — chain tests will fail'}`);
|
|
47
|
+
console.log('');
|
|
48
|
+
|
|
49
|
+
// ─── Suite runner ─────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const results = [];
|
|
52
|
+
|
|
53
|
+
function runSuite(name, file, env = {}) {
|
|
54
|
+
console.log(`\n${'─'.repeat(60)}`);
|
|
55
|
+
console.log(` SUITE: ${name}`);
|
|
56
|
+
console.log(`${'─'.repeat(60)}`);
|
|
57
|
+
|
|
58
|
+
const merged = { ...process.env, ...env };
|
|
59
|
+
const r = spawnSync('node', [file], { cwd: __dirname, env: merged, stdio: 'inherit' });
|
|
60
|
+
|
|
61
|
+
const ok = r.status === 0;
|
|
62
|
+
results.push({ name, ok, status: r.status });
|
|
63
|
+
if (!ok) console.log(`\n [SUITE FAILED — exit ${r.status}]`);
|
|
64
|
+
return ok;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Suite 1: Pure logic ───────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
runSuite('Logic (offline, no network)', 'test-all-logic.js');
|
|
70
|
+
|
|
71
|
+
if (offlineOnly) {
|
|
72
|
+
printSummary();
|
|
73
|
+
process.exit(results.every(r => r.ok) ? 0 : 1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Suite 2: FeeGrant E2E (offline + live LCD) ───────────────────────────
|
|
77
|
+
|
|
78
|
+
// Resolve the grantee address from the wallet if mnemonic is available.
|
|
79
|
+
let granteeAddr = FEE_GRANTEE;
|
|
80
|
+
if (MNEMONIC && !granteeAddr) {
|
|
81
|
+
try {
|
|
82
|
+
const { createWallet } = await import('./index.js');
|
|
83
|
+
const { account } = await createWallet(MNEMONIC);
|
|
84
|
+
granteeAddr = account.address;
|
|
85
|
+
} catch (_) {
|
|
86
|
+
// leave undefined — live LCD section will be skipped
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
runSuite('FeeGrant + isActiveStatus + error codes (offline + live LCD)',
|
|
91
|
+
'test-plan-connect-e2e.js',
|
|
92
|
+
{
|
|
93
|
+
E2E_LIVE: MNEMONIC ? '1' : '0',
|
|
94
|
+
FEE_GRANTER,
|
|
95
|
+
FEE_GRANTEE: granteeAddr || '',
|
|
96
|
+
PLAN_ID,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (quickMode) {
|
|
101
|
+
printSummary();
|
|
102
|
+
process.exit(results.every(r => r.ok) ? 0 : 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Suite 3: Mainnet — wallet + queries + WireGuard connect ─────────────
|
|
106
|
+
|
|
107
|
+
if (!MNEMONIC) {
|
|
108
|
+
console.log('\n SKIP Suite 3+4 — MNEMONIC not set');
|
|
109
|
+
} else {
|
|
110
|
+
runSuite('Mainnet (wallet, chain queries, WireGuard connect)', 'test-mainnet.js',
|
|
111
|
+
{ MNEMONIC });
|
|
112
|
+
|
|
113
|
+
// 60s gap between full suites to let chain settle
|
|
114
|
+
console.log('\n Waiting 60s between suites (chain settle)...');
|
|
115
|
+
await new Promise(r => setTimeout(r, 60000));
|
|
116
|
+
|
|
117
|
+
// ─── Suite 4: Subscription flows ─────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
runSuite('Subscription flows (subscribe, share, feegrant, onboard, renew, cancel)',
|
|
120
|
+
'test-subscription-flows.js', { MNEMONIC });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Summary ──────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
printSummary();
|
|
126
|
+
process.exit(results.every(r => r.ok) ? 0 : 1);
|
|
127
|
+
|
|
128
|
+
function printSummary() {
|
|
129
|
+
const pass = results.filter(r => r.ok).length;
|
|
130
|
+
const fail = results.filter(r => !r.ok).length;
|
|
131
|
+
console.log('\n');
|
|
132
|
+
console.log('═══════════════════════════════════════════════════════════');
|
|
133
|
+
console.log(` FINAL: ${pass} suite(s) passed, ${fail} failed`);
|
|
134
|
+
for (const r of results) {
|
|
135
|
+
console.log(` ${r.ok ? '✓' : '✗'} ${r.name}`);
|
|
136
|
+
}
|
|
137
|
+
console.log('═══════════════════════════════════════════════════════════');
|
|
138
|
+
}
|
package/test-mainnet.js
CHANGED
|
@@ -45,8 +45,8 @@ await t('1.2 balance > 0', async () => { console.log(' Balance:', formatP2P
|
|
|
45
45
|
// ═══ CHAIN QUERIES ═══
|
|
46
46
|
console.log('\n═══ CHAIN QUERIES ═══');
|
|
47
47
|
const allNodes = await fetchAllNodes();
|
|
48
|
-
await t('2.1 fetchAllNodes >
|
|
49
|
-
await t('2.2 nodes have pricing', async () => allNodes.filter(n => n.gigabyte_prices?.length > 0).length >
|
|
48
|
+
await t('2.1 fetchAllNodes > 100', async () => { console.log(' Nodes:', allNodes.length); return allNodes.length > 100; });
|
|
49
|
+
await t('2.2 nodes have pricing', async () => allNodes.filter(n => n.gigabyte_prices?.length > 0).length > 50);
|
|
50
50
|
const plans = await discoverPlans(undefined, { maxId: 30 });
|
|
51
51
|
await t('2.5 discoverPlans finds plans', async () => { console.log(' Plans:', plans.length); return plans.length > 0; });
|
|
52
52
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PLAN-SUBSCRIPTION CONNECT — E2E HARNESS
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* 1. Offline (default) — validates the code paths added for the 2026-04-23
|
|
7
|
+
* plan+feegrant audit without broadcasting:
|
|
8
|
+
* - isActiveStatus() strict-1 semantics
|
|
9
|
+
* - queryFeeGrant() shape on a fabricated LCD response
|
|
10
|
+
* - ErrorCodes.FEE_GRANT_MISSING_AT_START / FEE_GRANT_EXPIRED exported
|
|
11
|
+
* - userMessage() text parity with C# SentinelErrors.UserMessage
|
|
12
|
+
* - connectViaPlan argument plumbing (requireFeeGrant flag is accepted)
|
|
13
|
+
* No network calls. No TX. Safe to run anywhere, any time.
|
|
14
|
+
*
|
|
15
|
+
* 2. Live (E2E_LIVE=1) — calls queryFeeGrant + queryPlanDetails against
|
|
16
|
+
* mainnet LCD. No broadcast. Requires FEE_GRANTER + FEE_GRANTEE + PLAN_ID.
|
|
17
|
+
* Respects the SDK rule "never parallel chain tests" — runs serially.
|
|
18
|
+
*
|
|
19
|
+
* Run:
|
|
20
|
+
* node test-plan-connect-e2e.js # offline
|
|
21
|
+
* E2E_LIVE=1 FEE_GRANTER=sent1... FEE_GRANTEE=sent1... PLAN_ID=42 \
|
|
22
|
+
* node test-plan-connect-e2e.js # live (queries only)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
ErrorCodes,
|
|
27
|
+
isActiveStatus,
|
|
28
|
+
queryFeeGrant,
|
|
29
|
+
queryPlanDetails,
|
|
30
|
+
} from './index.js';
|
|
31
|
+
import { userMessage } from './errors.js';
|
|
32
|
+
import { RPC_ENDPOINTS } from './defaults.js';
|
|
33
|
+
|
|
34
|
+
// ─── Test harness ───────────────────────────────────────────
|
|
35
|
+
let pass = 0;
|
|
36
|
+
let fail = 0;
|
|
37
|
+
function assert(name, cond, detail = '') {
|
|
38
|
+
if (cond) { pass++; console.log(` ok ${name}`); }
|
|
39
|
+
else { fail++; console.log(` FAIL ${name}${detail ? ' — ' + detail : ''}`); }
|
|
40
|
+
}
|
|
41
|
+
function section(name) { console.log(`\n── ${name} ──`); }
|
|
42
|
+
|
|
43
|
+
// ─── 1. isActiveStatus strict-1 semantics ───────────────────
|
|
44
|
+
section('isActiveStatus');
|
|
45
|
+
assert('numeric 1 is active', isActiveStatus(1) === true);
|
|
46
|
+
assert('string "1" is active', isActiveStatus('1') === true);
|
|
47
|
+
assert('STATUS_ACTIVE is active', isActiveStatus('STATUS_ACTIVE') === true);
|
|
48
|
+
assert('numeric 2 is NOT active (status-inactive-pending)', isActiveStatus(2) === false);
|
|
49
|
+
assert('numeric 3 is NOT active (STATUS_INACTIVE — terminal)', isActiveStatus(3) === false);
|
|
50
|
+
assert('STATUS_INACTIVE_PENDING is NOT active', isActiveStatus('STATUS_INACTIVE_PENDING') === false);
|
|
51
|
+
assert('STATUS_INACTIVE is NOT active', isActiveStatus('STATUS_INACTIVE') === false);
|
|
52
|
+
assert('undefined is NOT active', isActiveStatus(undefined) === false);
|
|
53
|
+
assert('null is NOT active', isActiveStatus(null) === false);
|
|
54
|
+
|
|
55
|
+
// ─── 2. New error codes exported ─────────────────────────────
|
|
56
|
+
section('Error codes');
|
|
57
|
+
assert('FEE_GRANT_MISSING_AT_START exported', ErrorCodes.FEE_GRANT_MISSING_AT_START === 'FEE_GRANT_MISSING_AT_START');
|
|
58
|
+
assert('FEE_GRANT_EXPIRED exported', ErrorCodes.FEE_GRANT_EXPIRED === 'FEE_GRANT_EXPIRED');
|
|
59
|
+
assert('NODE_MISCONFIGURED exported', ErrorCodes.NODE_MISCONFIGURED === 'NODE_MISCONFIGURED');
|
|
60
|
+
assert('NODE_DB_CORRUPT exported', ErrorCodes.NODE_DB_CORRUPT === 'NODE_DB_CORRUPT');
|
|
61
|
+
assert('NODE_RPC_BROKEN exported', ErrorCodes.NODE_RPC_BROKEN === 'NODE_RPC_BROKEN');
|
|
62
|
+
assert('SEQUENCE_MISMATCH exported', ErrorCodes.SEQUENCE_MISMATCH === 'SEQUENCE_MISMATCH');
|
|
63
|
+
assert('NOT_CONNECTED exported', ErrorCodes.NOT_CONNECTED === 'NOT_CONNECTED');
|
|
64
|
+
assert('CONNECTION_IN_PROGRESS exported', ErrorCodes.CONNECTION_IN_PROGRESS === 'CONNECTION_IN_PROGRESS');
|
|
65
|
+
assert('HANDSHAKE_FAILED exported', ErrorCodes.HANDSHAKE_FAILED === 'HANDSHAKE_FAILED');
|
|
66
|
+
|
|
67
|
+
// ─── 3. User messages — parity with C# SentinelErrors ────────
|
|
68
|
+
// These strings must match C# UserMessage() verbatim. If a translator changes
|
|
69
|
+
// either side without the other, the JS↔C# error-UX contract breaks.
|
|
70
|
+
section('userMessage parity (JS side)');
|
|
71
|
+
const expectedMsgs = {
|
|
72
|
+
FEE_GRANT_MISSING_AT_START:
|
|
73
|
+
"Plan owner has not issued a fee grant to this wallet. Contact the plan provider.",
|
|
74
|
+
FEE_GRANT_EXPIRED:
|
|
75
|
+
"The plan owner's fee grant has expired. Contact the plan provider to renew.",
|
|
76
|
+
};
|
|
77
|
+
for (const [code, expected] of Object.entries(expectedMsgs)) {
|
|
78
|
+
const actual = userMessage(code);
|
|
79
|
+
assert(`userMessage(${code}) matches C# text`, actual === expected,
|
|
80
|
+
`got "${actual}", expected "${expected}"`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── 4. queryFeeGrant offline (fabricated LCD server) ────────
|
|
84
|
+
// Spin up a tiny HTTP server that returns the single-pair endpoint JSON shape.
|
|
85
|
+
// Verifies the SDK walks AllowedMsgAllowance → BasicAllowance correctly and
|
|
86
|
+
// surfaces spend_limit, expiration, allowed_messages, type_url flat.
|
|
87
|
+
section('queryFeeGrant (offline, fabricated LCD)');
|
|
88
|
+
import { createServer } from 'http';
|
|
89
|
+
import { once } from 'events';
|
|
90
|
+
|
|
91
|
+
async function withFakeLcd(responder, fn) {
|
|
92
|
+
const server = createServer((req, res) => {
|
|
93
|
+
try {
|
|
94
|
+
const out = responder(req);
|
|
95
|
+
res.writeHead(out.status, { 'Content-Type': 'application/json' });
|
|
96
|
+
res.end(JSON.stringify(out.body));
|
|
97
|
+
} catch (e) {
|
|
98
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
99
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
server.listen(0, '127.0.0.1');
|
|
103
|
+
await once(server, 'listening');
|
|
104
|
+
const port = server.address().port;
|
|
105
|
+
try {
|
|
106
|
+
return await fn(`http://127.0.0.1:${port}`);
|
|
107
|
+
} finally {
|
|
108
|
+
server.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Force LCD-only: empty RPC_ENDPOINTS so queryFeeGrant falls through to the
|
|
113
|
+
// fake LCD server instead of hitting real mainnet RPC.
|
|
114
|
+
const _savedRpcEndpoints = RPC_ENDPOINTS.splice(0, RPC_ENDPOINTS.length);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Case A: BasicAllowance — active grant
|
|
118
|
+
const grantA = {
|
|
119
|
+
status: 200,
|
|
120
|
+
body: {
|
|
121
|
+
allowance: {
|
|
122
|
+
granter: 'sent1granter',
|
|
123
|
+
grantee: 'sent1grantee',
|
|
124
|
+
allowance: {
|
|
125
|
+
'@type': '/cosmos.feegrant.v1beta1.BasicAllowance',
|
|
126
|
+
spend_limit: [{ denom: 'udvpn', amount: '5000000' }],
|
|
127
|
+
expiration: '2099-01-01T00:00:00Z',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
await withFakeLcd(() => grantA, async (lcd) => {
|
|
133
|
+
const r = await queryFeeGrant(lcd, 'sent1granter', 'sent1grantee');
|
|
134
|
+
assert('BasicAllowance returns non-null', r != null);
|
|
135
|
+
if (r) {
|
|
136
|
+
// queryFeeGrant returns the raw wrapper { granter, grantee, allowance: {...} }.
|
|
137
|
+
const inner = r.allowance || r;
|
|
138
|
+
const typeUrl = inner['@type'] || inner.typeUrl || inner.type_url;
|
|
139
|
+
assert('BasicAllowance.@type is BasicAllowance',
|
|
140
|
+
typeUrl === '/cosmos.feegrant.v1beta1.BasicAllowance',
|
|
141
|
+
`got ${typeUrl}`);
|
|
142
|
+
const exp = inner.expiration || inner.expiresAt;
|
|
143
|
+
assert('BasicAllowance exposes expiration', exp != null, `got ${exp}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Case B: AllowedMsgAllowance — wraps BasicAllowance
|
|
148
|
+
const grantB = {
|
|
149
|
+
status: 200,
|
|
150
|
+
body: {
|
|
151
|
+
allowance: {
|
|
152
|
+
granter: 'sent1granter',
|
|
153
|
+
grantee: 'sent1grantee',
|
|
154
|
+
allowance: {
|
|
155
|
+
'@type': '/cosmos.feegrant.v1beta1.AllowedMsgAllowance',
|
|
156
|
+
allowance: {
|
|
157
|
+
'@type': '/cosmos.feegrant.v1beta1.BasicAllowance',
|
|
158
|
+
spend_limit: [{ denom: 'udvpn', amount: '2000000' }],
|
|
159
|
+
expiration: '2099-01-01T00:00:00Z',
|
|
160
|
+
},
|
|
161
|
+
allowed_messages: ['/sentinel.plan.v3.MsgStartSessionRequest'],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
await withFakeLcd(() => grantB, async (lcd) => {
|
|
167
|
+
const r = await queryFeeGrant(lcd, 'sent1granter', 'sent1grantee');
|
|
168
|
+
assert('AllowedMsgAllowance returns non-null', r != null);
|
|
169
|
+
if (r) {
|
|
170
|
+
const inner = r.allowance || r;
|
|
171
|
+
const msgs = inner.allowed_messages || inner.allowedMessages;
|
|
172
|
+
assert('AllowedMsgAllowance surfaces allowed_messages',
|
|
173
|
+
Array.isArray(msgs) && msgs.length > 0,
|
|
174
|
+
`got ${JSON.stringify(msgs)}`);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Case C: 404 — no grant
|
|
179
|
+
const grantC = { status: 404, body: { code: 5, message: 'fee-grant not found' } };
|
|
180
|
+
await withFakeLcd(() => grantC, async (lcd) => {
|
|
181
|
+
const r = await queryFeeGrant(lcd, 'sent1granter', 'sent1grantee');
|
|
182
|
+
assert('404 returns null / exists=false',
|
|
183
|
+
r == null || r.exists === false,
|
|
184
|
+
`got ${JSON.stringify(r)}`);
|
|
185
|
+
});
|
|
186
|
+
} catch (e) {
|
|
187
|
+
fail++;
|
|
188
|
+
console.log(` FAIL queryFeeGrant offline harness threw: ${e.message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── 5. Live LCD queries (opt-in) ────────────────────────────
|
|
192
|
+
if (process.env.E2E_LIVE === '1') {
|
|
193
|
+
section('Live LCD (E2E_LIVE=1) — SEQUENTIAL, no broadcast');
|
|
194
|
+
const granter = process.env.FEE_GRANTER;
|
|
195
|
+
const grantee = process.env.FEE_GRANTEE;
|
|
196
|
+
const planId = process.env.PLAN_ID;
|
|
197
|
+
if (!granter || !grantee || !planId) {
|
|
198
|
+
console.log(' skip — set FEE_GRANTER, FEE_GRANTEE, PLAN_ID to run');
|
|
199
|
+
} else {
|
|
200
|
+
const lcd = process.env.LCD_URL || 'https://lcd.sentinel.co';
|
|
201
|
+
try {
|
|
202
|
+
const g = await queryFeeGrant(lcd, granter, grantee);
|
|
203
|
+
assert('live queryFeeGrant returned without throwing', true,
|
|
204
|
+
`result: ${JSON.stringify(g)}`);
|
|
205
|
+
console.log(' grant:', g);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
assert('live queryFeeGrant', false, e.message);
|
|
208
|
+
}
|
|
209
|
+
// 7s between chain touches per SDK CLAUDE.md
|
|
210
|
+
await new Promise(r => setTimeout(r, 7000));
|
|
211
|
+
try {
|
|
212
|
+
const p = await queryPlanDetails(planId, { lcdUrl: lcd });
|
|
213
|
+
assert('live queryPlanDetails returned without throwing', true);
|
|
214
|
+
if (p) {
|
|
215
|
+
assert('plan.provider looks like sentprov1',
|
|
216
|
+
typeof p.provider === 'string' && p.provider.startsWith('sentprov1'),
|
|
217
|
+
`got ${p.provider}`);
|
|
218
|
+
assert('plan.status is numeric-shaped',
|
|
219
|
+
typeof p.status === 'number' || /^\d+$/.test(String(p.status)),
|
|
220
|
+
`got ${p.status}`);
|
|
221
|
+
}
|
|
222
|
+
console.log(' plan:', p);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
assert('live queryPlanDetails', false, e.message);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
section('Live LCD');
|
|
229
|
+
console.log(' skip — set E2E_LIVE=1 to run live mainnet queries');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Report ──────────────────────────────────────────────────
|
|
233
|
+
console.log(`\n──────────────`);
|
|
234
|
+
console.log(` ${pass} pass, ${fail} fail`);
|
|
235
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
@@ -205,10 +205,20 @@ if (balance.udvpn < 500_000) {
|
|
|
205
205
|
} else {
|
|
206
206
|
|
|
207
207
|
await t('3.1 shareSubscription — self-share 1 GB', async () => {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
208
|
+
let result;
|
|
209
|
+
try {
|
|
210
|
+
result = await shareSubscription(client, address, subId, address, BYTES_1GB);
|
|
211
|
+
console.log(`\n Share TX hash: ${result.txHash}`);
|
|
212
|
+
state.shareTxHash = result.txHash;
|
|
213
|
+
return result;
|
|
214
|
+
} catch (e) {
|
|
215
|
+
// insufficient bytes = existing allocation is smaller than requested. Not an SDK bug.
|
|
216
|
+
if (e.message?.includes('insufficient bytes') || e.message?.includes('already exists')) {
|
|
217
|
+
console.log(`\n Share rejected (${e.message.includes('insufficient bytes') ? 'insufficient allocation remaining — expected on repeated runs' : 'already exists'})`);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
212
222
|
});
|
|
213
223
|
|
|
214
224
|
console.log(' Waiting 7s for chain propagation...');
|
package/types/connection.d.ts
CHANGED
|
@@ -302,8 +302,12 @@ export class ConnectionState {
|
|
|
302
302
|
systemProxy: boolean;
|
|
303
303
|
/** Saved proxy state for restoration on disconnect */
|
|
304
304
|
savedProxyState: unknown;
|
|
305
|
-
/**
|
|
306
|
-
|
|
305
|
+
/**
|
|
306
|
+
* @internal Derived OfflineSigner wallet, used by _endSessionOnChain on disconnect.
|
|
307
|
+
* v37 (security): replaced `_mnemonic: string | null`. The raw BIP-39 phrase is no
|
|
308
|
+
* longer kept on long-lived state.
|
|
309
|
+
*/
|
|
310
|
+
_wallet: unknown;
|
|
307
311
|
/** Whether a tunnel is currently active */
|
|
308
312
|
readonly isConnected: boolean;
|
|
309
313
|
/** Remove this state from the global cleanup registry */
|
package/types/index.d.ts
CHANGED
|
@@ -469,8 +469,8 @@ export function isMnemonicValid(mnemonic: string): boolean;
|
|
|
469
469
|
/** Get current P2P price in USD */
|
|
470
470
|
export function getDvpnPrice(): Promise<number>;
|
|
471
471
|
|
|
472
|
-
/** Find existing active session for wallet+node pair */
|
|
473
|
-
export function findExistingSession(lcdUrl: string, walletAddr: string, nodeAddr: string): Promise<bigint | null>;
|
|
472
|
+
/** Find existing active session for wallet+node pair. Deduplicates stale sessions via onStaleDuplicate callback. */
|
|
473
|
+
export function findExistingSession(lcdUrl: string, walletAddr: string, nodeAddr: string, opts?: { onStaleDuplicate?: (sessionId: bigint) => void }): Promise<bigint | null>;
|
|
474
474
|
|
|
475
475
|
/** Fetch active nodes from LCD with pagination */
|
|
476
476
|
export function fetchActiveNodes(lcdUrl: string, limit?: number, maxPages?: number): Promise<unknown[]>;
|