@blockrun/franklin 3.17.0 → 3.19.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.
@@ -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
- 'Access-Control-Allow-Origin': '*',
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 (!isLoopback(req)) {
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 (!isLoopback(req)) {
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 (!isLoopback(req)) {
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
+ }