@blockrun/franklin 3.16.4 → 3.18.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/dist/agent/context.js +11 -1
- package/dist/agent/error-classifier.js +15 -0
- package/dist/agent/loop.js +8 -1
- package/dist/agent/permissions.js +2 -2
- package/dist/agent/tool-guard.js +33 -0
- package/dist/panel/html.js +403 -0
- package/dist/panel/server.js +217 -6
- package/dist/phone/cache.d.ts +44 -0
- package/dist/phone/cache.js +74 -0
- package/dist/phone/client.d.ts +50 -0
- package/dist/phone/client.js +162 -0
- package/dist/social/browser.js +97 -12
- package/dist/tools/browsex.d.ts +17 -0
- package/dist/tools/browsex.js +156 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/searchx.d.ts +1 -0
- package/dist/tools/searchx.js +121 -8
- package/dist/tools/webfetch.js +2 -2
- package/package.json +1 -1
package/dist/panel/server.js
CHANGED
|
@@ -7,6 +7,8 @@ import http from 'node:http';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { loadChain, saveChain } from '../config.js';
|
|
10
|
+
import { listNumbers as gatewayListNumbers, renewNumber as gatewayRenewNumber, buyNumber as gatewayBuyNumber, releaseNumber as gatewayReleaseNumber, } from '../phone/client.js';
|
|
11
|
+
import { readCache as readPhoneCache, writeCache as writePhoneCache, clearCache as clearPhoneCache, isFresh as isPhoneCacheFresh, } from '../phone/cache.js';
|
|
10
12
|
import { getStatsSummary, getStatsFilePath } from '../stats/tracker.js';
|
|
11
13
|
import { generateInsights } from '../stats/insights.js';
|
|
12
14
|
import { listSessions, loadSessionHistory } from '../session/storage.js';
|
|
@@ -24,7 +26,7 @@ const sseClients = new Set();
|
|
|
24
26
|
function json(res, data, status = 200) {
|
|
25
27
|
res.writeHead(status, {
|
|
26
28
|
'Content-Type': 'application/json',
|
|
27
|
-
'
|
|
29
|
+
'Cache-Control': 'no-store',
|
|
28
30
|
});
|
|
29
31
|
res.end(JSON.stringify(data));
|
|
30
32
|
}
|
|
@@ -37,6 +39,39 @@ function isLoopback(req) {
|
|
|
37
39
|
const addr = req.socket.remoteAddress || '';
|
|
38
40
|
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
|
|
39
41
|
}
|
|
42
|
+
function isLocalHostname(hostname) {
|
|
43
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Loopback binding prevents LAN exposure, but it does not stop a malicious
|
|
47
|
+
* website open in the user's browser from issuing requests to localhost.
|
|
48
|
+
* Browsers attach Origin on cross-origin fetches, so spendful and
|
|
49
|
+
* wallet-mutating routes require either no Origin (curl/direct navigation)
|
|
50
|
+
* or the exact same local origin that served the panel page.
|
|
51
|
+
*/
|
|
52
|
+
function isTrustedPanelOrigin(req) {
|
|
53
|
+
const origin = req.headers.origin;
|
|
54
|
+
if (!origin)
|
|
55
|
+
return true;
|
|
56
|
+
if (Array.isArray(origin))
|
|
57
|
+
return false;
|
|
58
|
+
const host = req.headers.host;
|
|
59
|
+
if (!host)
|
|
60
|
+
return false;
|
|
61
|
+
try {
|
|
62
|
+
const originUrl = new URL(origin);
|
|
63
|
+
const hostUrl = new URL(`http://${host}`);
|
|
64
|
+
return originUrl.protocol === 'http:' &&
|
|
65
|
+
originUrl.host === hostUrl.host &&
|
|
66
|
+
isLocalHostname(originUrl.hostname);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function isLocalPanelRequest(req) {
|
|
73
|
+
return isLoopback(req) && isTrustedPanelOrigin(req);
|
|
74
|
+
}
|
|
40
75
|
async function readBody(req, maxBytes = 16 * 1024) {
|
|
41
76
|
return new Promise((resolve, reject) => {
|
|
42
77
|
let size = 0;
|
|
@@ -65,6 +100,25 @@ function broadcast(data) {
|
|
|
65
100
|
}
|
|
66
101
|
}
|
|
67
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the current active wallet address (Base or Solana, depending on
|
|
105
|
+
* the active chain). Used by phone endpoints that key the cache by wallet,
|
|
106
|
+
* and that must sign x402 payments out of the wallet the user owns.
|
|
107
|
+
*
|
|
108
|
+
* Throws if no wallet exists yet — the UI handles this by showing an
|
|
109
|
+
* empty state with a "Create wallet" CTA before any phone calls can be made.
|
|
110
|
+
*/
|
|
111
|
+
async function currentWalletAddress() {
|
|
112
|
+
const chain = loadChain();
|
|
113
|
+
if (chain === 'solana') {
|
|
114
|
+
const { setupAgentSolanaWallet } = await import('@blockrun/llm');
|
|
115
|
+
const client = await setupAgentSolanaWallet({ silent: true });
|
|
116
|
+
return await client.getWalletAddress();
|
|
117
|
+
}
|
|
118
|
+
const { setupAgentWallet } = await import('@blockrun/llm');
|
|
119
|
+
const client = setupAgentWallet({ silent: true });
|
|
120
|
+
return client.getWalletAddress();
|
|
121
|
+
}
|
|
68
122
|
export function createPanelServer(port) {
|
|
69
123
|
const html = getHTML();
|
|
70
124
|
const server = http.createServer(async (req, res) => {
|
|
@@ -239,9 +293,9 @@ export function createPanelServer(port) {
|
|
|
239
293
|
// ─── Wallet secret (loopback only) ──────────────────────────────────
|
|
240
294
|
// Returns the private key so the user can back it up / move it.
|
|
241
295
|
// Hardened: loopback-only (belt-and-suspenders on the 127.0.0.1 bind),
|
|
242
|
-
// no-store cache header, JSON only.
|
|
296
|
+
// same-origin for browser requests, no-store cache header, JSON only.
|
|
243
297
|
if (p === '/api/wallet/secret') {
|
|
244
|
-
if (!
|
|
298
|
+
if (!isLocalPanelRequest(req)) {
|
|
245
299
|
json(res, { error: 'forbidden' }, 403);
|
|
246
300
|
return;
|
|
247
301
|
}
|
|
@@ -271,9 +325,9 @@ export function createPanelServer(port) {
|
|
|
271
325
|
// ─── Wallet import (loopback only) ──────────────────────────────────
|
|
272
326
|
// Overwrites the local wallet with a user-supplied private key.
|
|
273
327
|
// Destructive — overwrites the existing wallet file without backup,
|
|
274
|
-
// so the UI warns the user. Loopback-only.
|
|
328
|
+
// so the UI warns the user. Loopback + same-origin only.
|
|
275
329
|
if (p === '/api/wallet/import' && req.method === 'POST') {
|
|
276
|
-
if (!
|
|
330
|
+
if (!isLocalPanelRequest(req)) {
|
|
277
331
|
json(res, { error: 'forbidden' }, 403);
|
|
278
332
|
return;
|
|
279
333
|
}
|
|
@@ -329,7 +383,7 @@ export function createPanelServer(port) {
|
|
|
329
383
|
// reads and for the *next* agent invocation, but won't flip chain
|
|
330
384
|
// mid-session for an already-running agent. UI copy makes this clear.
|
|
331
385
|
if (p === '/api/chain' && req.method === 'POST') {
|
|
332
|
-
if (!
|
|
386
|
+
if (!isLocalPanelRequest(req)) {
|
|
333
387
|
json(res, { error: 'forbidden' }, 403);
|
|
334
388
|
return;
|
|
335
389
|
}
|
|
@@ -364,6 +418,163 @@ export function createPanelServer(port) {
|
|
|
364
418
|
}
|
|
365
419
|
return;
|
|
366
420
|
}
|
|
421
|
+
// ─── Phone & Voice ──────────────────────────────────────────────────
|
|
422
|
+
// GET /api/phone/numbers — list wallet-owned numbers (cached)
|
|
423
|
+
// POST /api/phone/numbers/refresh — force-refresh from BlockRun ($0.001)
|
|
424
|
+
// POST /api/phone/numbers/renew — extend lease 30d ($5)
|
|
425
|
+
// POST /api/phone/numbers/buy — provision new number ($5)
|
|
426
|
+
// POST /api/phone/numbers/release — release (free)
|
|
427
|
+
//
|
|
428
|
+
// Renewals are explicit user clicks. No silent auto-renew: a wallet
|
|
429
|
+
// that runs dry between charges would fail the renewal and surprise
|
|
430
|
+
// the user. Notifications at T-7/3/1 days keep them in the loop.
|
|
431
|
+
//
|
|
432
|
+
// All spendful/mutating endpoints are loopback + same-origin because
|
|
433
|
+
// they spend money out of the user's wallet. Even the list endpoint can
|
|
434
|
+
// cost $0.001 on cache miss, so it gets the same browser-origin guard.
|
|
435
|
+
if (p === '/api/phone/numbers' && (!req.method || req.method === 'GET')) {
|
|
436
|
+
if (!isLocalPanelRequest(req)) {
|
|
437
|
+
json(res, { error: 'forbidden', numbers: [] }, 403);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const wallet = await currentWalletAddress();
|
|
442
|
+
const chain = loadChain();
|
|
443
|
+
const cache = readPhoneCache();
|
|
444
|
+
if (cache && isPhoneCacheFresh(cache, wallet, chain)) {
|
|
445
|
+
json(res, {
|
|
446
|
+
wallet,
|
|
447
|
+
chain,
|
|
448
|
+
fetchedAt: cache.fetchedAt,
|
|
449
|
+
fromCache: true,
|
|
450
|
+
numbers: cache.numbers,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Cache stale or missing — fetch fresh (costs $0.001).
|
|
455
|
+
// We pay through the panel's wallet, which is the same wallet
|
|
456
|
+
// that owns the numbers, so the gateway returns this user's list.
|
|
457
|
+
const fresh = await gatewayListNumbers({ walletAddress: wallet });
|
|
458
|
+
json(res, {
|
|
459
|
+
wallet,
|
|
460
|
+
chain,
|
|
461
|
+
fetchedAt: Date.now(),
|
|
462
|
+
fromCache: false,
|
|
463
|
+
paid: fresh.paid,
|
|
464
|
+
numbers: fresh.numbers,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
json(res, { error: err.message, numbers: [] }, 500);
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (p === '/api/phone/numbers/refresh' && req.method === 'POST') {
|
|
473
|
+
if (!isLocalPanelRequest(req)) {
|
|
474
|
+
json(res, { error: 'forbidden' }, 403);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const wallet = await currentWalletAddress();
|
|
479
|
+
clearPhoneCache();
|
|
480
|
+
const fresh = await gatewayListNumbers({ walletAddress: wallet });
|
|
481
|
+
broadcast({ type: 'phone.refreshed' });
|
|
482
|
+
json(res, {
|
|
483
|
+
wallet,
|
|
484
|
+
chain: loadChain(),
|
|
485
|
+
paid: fresh.paid,
|
|
486
|
+
numbers: fresh.numbers,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
json(res, { error: err.message }, 500);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (p === '/api/phone/numbers/renew' && req.method === 'POST') {
|
|
495
|
+
if (!isLocalPanelRequest(req)) {
|
|
496
|
+
json(res, { error: 'forbidden' }, 403);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const raw = await readBody(req);
|
|
501
|
+
const body = JSON.parse(raw);
|
|
502
|
+
const target = (body.phoneNumber || '').trim();
|
|
503
|
+
if (!target) {
|
|
504
|
+
json(res, { error: 'phoneNumber required' }, 400);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const result = await gatewayRenewNumber(target);
|
|
508
|
+
// Patch the cache in place so the panel UI gets the new expiry
|
|
509
|
+
// without a follow-up $0.001 list call.
|
|
510
|
+
const cache = readPhoneCache();
|
|
511
|
+
if (cache) {
|
|
512
|
+
const idx = cache.numbers.findIndex(n => n.phone_number === target);
|
|
513
|
+
if (idx >= 0) {
|
|
514
|
+
cache.numbers[idx] = {
|
|
515
|
+
...cache.numbers[idx],
|
|
516
|
+
expires_at: result.expires_at,
|
|
517
|
+
active: true,
|
|
518
|
+
};
|
|
519
|
+
writePhoneCache({ wallet: cache.wallet, chain: cache.chain, numbers: cache.numbers });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
broadcast({ type: 'phone.renewed', phoneNumber: target, expires_at: result.expires_at });
|
|
523
|
+
json(res, { ok: true, ...result });
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
json(res, { error: err.message }, 500);
|
|
527
|
+
}
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (p === '/api/phone/numbers/buy' && req.method === 'POST') {
|
|
531
|
+
if (!isLocalPanelRequest(req)) {
|
|
532
|
+
json(res, { error: 'forbidden' }, 403);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const raw = await readBody(req);
|
|
537
|
+
const body = JSON.parse(raw);
|
|
538
|
+
const result = await gatewayBuyNumber({
|
|
539
|
+
country: body.country,
|
|
540
|
+
areaCode: body.areaCode,
|
|
541
|
+
});
|
|
542
|
+
clearPhoneCache(); // forces next /api/phone/numbers to re-list
|
|
543
|
+
broadcast({ type: 'phone.bought', phoneNumber: result.phone_number });
|
|
544
|
+
json(res, { ok: true, ...result });
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
json(res, { error: err.message }, 500);
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (p === '/api/phone/numbers/release' && req.method === 'POST') {
|
|
552
|
+
if (!isLocalPanelRequest(req)) {
|
|
553
|
+
json(res, { error: 'forbidden' }, 403);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
const raw = await readBody(req);
|
|
558
|
+
const body = JSON.parse(raw);
|
|
559
|
+
const target = (body.phoneNumber || '').trim();
|
|
560
|
+
if (!target) {
|
|
561
|
+
json(res, { error: 'phoneNumber required' }, 400);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const result = await gatewayReleaseNumber(target);
|
|
565
|
+
const cache = readPhoneCache();
|
|
566
|
+
if (cache) {
|
|
567
|
+
const next = cache.numbers.filter(n => n.phone_number !== target);
|
|
568
|
+
writePhoneCache({ wallet: cache.wallet, chain: cache.chain, numbers: next });
|
|
569
|
+
}
|
|
570
|
+
broadcast({ type: 'phone.released', phoneNumber: target });
|
|
571
|
+
json(res, { ok: true, ...result });
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
json(res, { error: err.message }, 500);
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
367
578
|
if (p === '/api/markets') {
|
|
368
579
|
// Snapshot of every active data provider for the Markets panel:
|
|
369
580
|
// pipeline wiring (which endpoint serves which asset class), live
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cache for the user's BlockRun-provisioned phone numbers.
|
|
3
|
+
*
|
|
4
|
+
* Why a cache: `POST /v1/phone/numbers/list` costs $0.001 per call. The
|
|
5
|
+
* panel ticks countdowns once per minute and the terminal status bar
|
|
6
|
+
* re-renders on every prompt cycle — both surfaces hitting the gateway
|
|
7
|
+
* directly would burn pointless micropayments. We hit the gateway only
|
|
8
|
+
* on cache-miss, panel-reload, or after a state-changing call (buy /
|
|
9
|
+
* renew / release).
|
|
10
|
+
*
|
|
11
|
+
* Storage: ~/.blockrun/phone-numbers.json. Read by both the Ink terminal
|
|
12
|
+
* status bar and the web panel, so they always agree on which numbers
|
|
13
|
+
* the wallet owns and how many days remain.
|
|
14
|
+
*/
|
|
15
|
+
import { type Chain } from '../config.js';
|
|
16
|
+
export interface PhoneNumberRecord {
|
|
17
|
+
phone_number: string;
|
|
18
|
+
chain: Chain;
|
|
19
|
+
expires_at: string;
|
|
20
|
+
active: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface CacheFile {
|
|
23
|
+
fetchedAt: number;
|
|
24
|
+
wallet: string;
|
|
25
|
+
chain: Chain;
|
|
26
|
+
numbers: PhoneNumberRecord[];
|
|
27
|
+
}
|
|
28
|
+
/** 6 hours — long enough to not thrash the gateway, short enough that
|
|
29
|
+
* a number provisioned on another device shows up the same day. */
|
|
30
|
+
export declare const CACHE_TTL_MS: number;
|
|
31
|
+
export declare function readCache(): CacheFile | null;
|
|
32
|
+
export declare function writeCache(data: {
|
|
33
|
+
wallet: string;
|
|
34
|
+
chain: Chain;
|
|
35
|
+
numbers: PhoneNumberRecord[];
|
|
36
|
+
}): void;
|
|
37
|
+
export declare function clearCache(): void;
|
|
38
|
+
export declare function isFresh(cache: CacheFile | null, wallet: string, chain: Chain): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Compute days-remaining for a number's lease. Negative when expired.
|
|
41
|
+
* UI uses this for the colour ladder (green / amber / red).
|
|
42
|
+
*/
|
|
43
|
+
export declare function daysRemaining(expiresAt: string): number;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cache for the user's BlockRun-provisioned phone numbers.
|
|
3
|
+
*
|
|
4
|
+
* Why a cache: `POST /v1/phone/numbers/list` costs $0.001 per call. The
|
|
5
|
+
* panel ticks countdowns once per minute and the terminal status bar
|
|
6
|
+
* re-renders on every prompt cycle — both surfaces hitting the gateway
|
|
7
|
+
* directly would burn pointless micropayments. We hit the gateway only
|
|
8
|
+
* on cache-miss, panel-reload, or after a state-changing call (buy /
|
|
9
|
+
* renew / release).
|
|
10
|
+
*
|
|
11
|
+
* Storage: ~/.blockrun/phone-numbers.json. Read by both the Ink terminal
|
|
12
|
+
* status bar and the web panel, so they always agree on which numbers
|
|
13
|
+
* the wallet owns and how many days remain.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
18
|
+
const CACHE_PATH = path.join(BLOCKRUN_DIR, 'phone-numbers.json');
|
|
19
|
+
/** 6 hours — long enough to not thrash the gateway, short enough that
|
|
20
|
+
* a number provisioned on another device shows up the same day. */
|
|
21
|
+
export const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
22
|
+
export function readCache() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!parsed || !Array.isArray(parsed.numbers))
|
|
27
|
+
return null;
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function writeCache(data) {
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
37
|
+
const payload = {
|
|
38
|
+
fetchedAt: Date.now(),
|
|
39
|
+
wallet: data.wallet,
|
|
40
|
+
chain: data.chain,
|
|
41
|
+
numbers: data.numbers,
|
|
42
|
+
};
|
|
43
|
+
fs.writeFileSync(CACHE_PATH, JSON.stringify(payload, null, 2) + '\n', {
|
|
44
|
+
mode: 0o600,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* best-effort — cache is an optimization, not a source of truth */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function clearCache() {
|
|
52
|
+
try {
|
|
53
|
+
fs.unlinkSync(CACHE_PATH);
|
|
54
|
+
}
|
|
55
|
+
catch { /* not there, fine */ }
|
|
56
|
+
}
|
|
57
|
+
export function isFresh(cache, wallet, chain) {
|
|
58
|
+
if (!cache)
|
|
59
|
+
return false;
|
|
60
|
+
if (cache.wallet !== wallet)
|
|
61
|
+
return false;
|
|
62
|
+
if (cache.chain !== chain)
|
|
63
|
+
return false;
|
|
64
|
+
return Date.now() - cache.fetchedAt < CACHE_TTL_MS;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compute days-remaining for a number's lease. Negative when expired.
|
|
68
|
+
* UI uses this for the colour ladder (green / amber / red).
|
|
69
|
+
*/
|
|
70
|
+
export function daysRemaining(expiresAt) {
|
|
71
|
+
const expiry = new Date(expiresAt).getTime();
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
return Math.floor((expiry - now) / (24 * 60 * 60 * 1000));
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockRun phone API client.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over `/v1/phone/numbers/{list,buy,renew,release}` that
|
|
5
|
+
* handles the x402 payment handshake using the wallet Franklin already
|
|
6
|
+
* loads at startup. Pattern mirrors `src/tools/modal.ts` — first POST
|
|
7
|
+
* returns 402 with payment requirements, we sign with the local wallet,
|
|
8
|
+
* retry once with X-PAYMENT.
|
|
9
|
+
*
|
|
10
|
+
* Used by:
|
|
11
|
+
* - panel server (renew button, buy flow, refresh button)
|
|
12
|
+
* - phone cache refresh (background)
|
|
13
|
+
* - future Phone/Call tools surfaced to the agent
|
|
14
|
+
*/
|
|
15
|
+
import { type Chain } from '../config.js';
|
|
16
|
+
import { type PhoneNumberRecord } from './cache.js';
|
|
17
|
+
export interface ListNumbersResult {
|
|
18
|
+
numbers: PhoneNumberRecord[];
|
|
19
|
+
count: number;
|
|
20
|
+
/** $0.001 was paid for this list call. UI can show it. */
|
|
21
|
+
paid: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fetch the wallet's owned numbers from BlockRun. Refreshes the local
|
|
25
|
+
* cache on success so the terminal status bar picks up the same data.
|
|
26
|
+
*/
|
|
27
|
+
export declare function listNumbers(opts: {
|
|
28
|
+
walletAddress: string;
|
|
29
|
+
}): Promise<ListNumbersResult>;
|
|
30
|
+
export interface RenewResult {
|
|
31
|
+
phone_number: string;
|
|
32
|
+
expires_at: string;
|
|
33
|
+
paid: number;
|
|
34
|
+
}
|
|
35
|
+
export declare function renewNumber(phoneNumber: string): Promise<RenewResult>;
|
|
36
|
+
export interface BuyResult {
|
|
37
|
+
phone_number: string;
|
|
38
|
+
expires_at: string;
|
|
39
|
+
chain: Chain;
|
|
40
|
+
paid: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function buyNumber(opts: {
|
|
43
|
+
country?: string;
|
|
44
|
+
areaCode?: string;
|
|
45
|
+
}): Promise<BuyResult>;
|
|
46
|
+
export interface ReleaseResult {
|
|
47
|
+
released: boolean;
|
|
48
|
+
phone_number: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function releaseNumber(phoneNumber: string): Promise<ReleaseResult>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockRun phone API client.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over `/v1/phone/numbers/{list,buy,renew,release}` that
|
|
5
|
+
* handles the x402 payment handshake using the wallet Franklin already
|
|
6
|
+
* loads at startup. Pattern mirrors `src/tools/modal.ts` — first POST
|
|
7
|
+
* returns 402 with payment requirements, we sign with the local wallet,
|
|
8
|
+
* retry once with X-PAYMENT.
|
|
9
|
+
*
|
|
10
|
+
* Used by:
|
|
11
|
+
* - panel server (renew button, buy flow, refresh button)
|
|
12
|
+
* - phone cache refresh (background)
|
|
13
|
+
* - future Phone/Call tools surfaced to the agent
|
|
14
|
+
*/
|
|
15
|
+
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
16
|
+
import { loadChain, API_URLS, USER_AGENT } from '../config.js';
|
|
17
|
+
import { writeCache } from './cache.js';
|
|
18
|
+
function phoneEndpoint(chain, path) {
|
|
19
|
+
return `${API_URLS[chain]}/v1/phone/${path}`;
|
|
20
|
+
}
|
|
21
|
+
async function extractPaymentReq(response) {
|
|
22
|
+
let header = response.headers.get('payment-required');
|
|
23
|
+
if (!header) {
|
|
24
|
+
try {
|
|
25
|
+
const body = (await response.clone().json());
|
|
26
|
+
if (body.x402 || body.accepts)
|
|
27
|
+
header = btoa(JSON.stringify(body));
|
|
28
|
+
}
|
|
29
|
+
catch { /* not JSON, no header */ }
|
|
30
|
+
}
|
|
31
|
+
return header;
|
|
32
|
+
}
|
|
33
|
+
async function signPayment(response, chain, endpoint, resourceDescription) {
|
|
34
|
+
const paymentHeader = await extractPaymentReq(response);
|
|
35
|
+
if (!paymentHeader)
|
|
36
|
+
return null;
|
|
37
|
+
if (chain === 'solana') {
|
|
38
|
+
const wallet = await getOrCreateSolanaWallet();
|
|
39
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
40
|
+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
41
|
+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
42
|
+
const feePayer = details.extra?.feePayer || details.recipient;
|
|
43
|
+
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
44
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
45
|
+
resourceDescription: details.resource?.description || resourceDescription,
|
|
46
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
47
|
+
extra: details.extra,
|
|
48
|
+
});
|
|
49
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const wallet = getOrCreateWallet();
|
|
53
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
54
|
+
const details = extractPaymentDetails(paymentRequired);
|
|
55
|
+
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
56
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
57
|
+
resourceDescription: details.resource?.description || resourceDescription,
|
|
58
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
59
|
+
extra: details.extra,
|
|
60
|
+
});
|
|
61
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function postWithPayment(endpoint, body, resourceDescription, timeoutMs = 30_000) {
|
|
65
|
+
const chain = loadChain();
|
|
66
|
+
const headers = {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
'User-Agent': USER_AGENT,
|
|
69
|
+
};
|
|
70
|
+
const ctrl = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
72
|
+
try {
|
|
73
|
+
const payload = JSON.stringify(body);
|
|
74
|
+
let response = await fetch(endpoint, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
signal: ctrl.signal,
|
|
77
|
+
headers,
|
|
78
|
+
body: payload,
|
|
79
|
+
});
|
|
80
|
+
if (response.status === 402) {
|
|
81
|
+
const paymentHeaders = await signPayment(response, chain, endpoint, resourceDescription);
|
|
82
|
+
if (!paymentHeaders) {
|
|
83
|
+
return { ok: false, status: 402, body: { error: 'payment signing failed' }, raw: '' };
|
|
84
|
+
}
|
|
85
|
+
response = await fetch(endpoint, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
signal: ctrl.signal,
|
|
88
|
+
headers: { ...headers, ...paymentHeaders },
|
|
89
|
+
body: payload,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const raw = await response.text().catch(() => '');
|
|
93
|
+
let parsed = {};
|
|
94
|
+
try {
|
|
95
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
96
|
+
}
|
|
97
|
+
catch { /* leave as {} */ }
|
|
98
|
+
return { ok: response.ok, status: response.status, body: parsed, raw };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fetch the wallet's owned numbers from BlockRun. Refreshes the local
|
|
106
|
+
* cache on success so the terminal status bar picks up the same data.
|
|
107
|
+
*/
|
|
108
|
+
export async function listNumbers(opts) {
|
|
109
|
+
const chain = loadChain();
|
|
110
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/list'), {}, 'List wallet-owned BlockRun phone numbers');
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
113
|
+
throw new Error(message);
|
|
114
|
+
}
|
|
115
|
+
const numbers = Array.isArray(result.body.numbers)
|
|
116
|
+
? result.body.numbers
|
|
117
|
+
: [];
|
|
118
|
+
writeCache({ wallet: opts.walletAddress, chain, numbers });
|
|
119
|
+
return { numbers, count: numbers.length, paid: 0.001 };
|
|
120
|
+
}
|
|
121
|
+
export async function renewNumber(phoneNumber) {
|
|
122
|
+
const chain = loadChain();
|
|
123
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/renew'), { phoneNumber }, `Renew BlockRun phone number ${phoneNumber}`);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
126
|
+
throw new Error(message);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
phone_number: String(result.body.phone_number ?? phoneNumber),
|
|
130
|
+
expires_at: String(result.body.expires_at ?? ''),
|
|
131
|
+
paid: 5.0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export async function buyNumber(opts) {
|
|
135
|
+
const chain = loadChain();
|
|
136
|
+
const body = { country: opts.country || 'US' };
|
|
137
|
+
if (opts.areaCode)
|
|
138
|
+
body.areaCode = opts.areaCode;
|
|
139
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/buy'), body, `Provision a new BlockRun phone number (${opts.country || 'US'})`);
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
142
|
+
throw new Error(message);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
phone_number: String(result.body.phone_number ?? ''),
|
|
146
|
+
expires_at: String(result.body.expires_at ?? ''),
|
|
147
|
+
chain: result.body.chain || chain,
|
|
148
|
+
paid: 5.0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export async function releaseNumber(phoneNumber) {
|
|
152
|
+
const chain = loadChain();
|
|
153
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/release'), { phoneNumber }, `Release BlockRun phone number ${phoneNumber}`);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
156
|
+
throw new Error(message);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
released: Boolean(result.body.released),
|
|
160
|
+
phone_number: String(result.body.phone_number ?? phoneNumber),
|
|
161
|
+
};
|
|
162
|
+
}
|