@elisym/cli 0.25.1 → 0.26.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/index.js CHANGED
@@ -2,27 +2,27 @@
2
2
  import { ReadableStream } from 'node:stream/web';
3
3
  import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync, appendFileSync, unlinkSync, rmdirSync, realpathSync } from 'node:fs';
4
4
  import { dirname, join, resolve, basename, extname, relative, sep } from 'node:path';
5
- import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, formatSol, formatAssetAmount, USDC_SOLANA_DEVNET, signerFromSecretKeyBase58, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, createBlossomTransport, verifyAgentIdentities, generateSolanaWallet, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, encodeSecretKeyBase58, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
6
- import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, ensureGitignoreHasX402Entries, ensureGitignoreHasSessionsEntry, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
7
- import { isAddress, createSolanaRpc, address } from '@solana/kit';
5
+ import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, formatSol, formatAssetAmount, USDC_SOLANA_DEVNET, signerFromSecretKeyBase58, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
6
+ import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, ensureGitignoreHasX402Entries, ensureGitignoreHasSessionsEntry, ensureGitignoreHasDelegationNoncesEntry, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
7
+ import { isAddress, createSolanaRpc, address, signature } from '@solana/kit';
8
8
  import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools';
9
- import YAML from 'yaml';
9
+ import YAML2 from 'yaml';
10
10
  import { Command } from 'commander';
11
+ import { mkdir, writeFile, readFile, mkdtemp, rm, rename, stat } from 'node:fs/promises';
12
+ import { parseSkillMd, validateSkillFrontmatter, DEFAULT_X402_MAX_INPUT_BYTES, resolveInsidePathReal, DEFAULT_SCRIPT_TIMEOUT_MS, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
13
+ import chalk from 'chalk';
14
+ import Decimal from 'decimal.js-light';
15
+ import { decodePaymentRequiredHeader } from '@x402/core/http';
11
16
  import { createHash } from 'node:crypto';
12
17
  import { LlmHealthMonitor, startLlmRecovery, createFreeLlmLimiterSet, ScriptBillingExhaustedError, ScriptExecutionError, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey, LlmHealthError } from '@elisym/sdk/llm-health';
13
18
  import { createIrohTransport } from '@elisym/sdk/node';
14
19
  import { lookup } from 'node:dns/promises';
15
20
  import { Socket } from 'node:net';
16
21
  import pino from 'pino';
17
- import { mkdir, writeFile, readFile, mkdtemp, rm, rename, stat } from 'node:fs/promises';
18
22
  import { tmpdir } from 'node:os';
19
23
  import pLimit from 'p-limit';
20
- import { parseSkillMd, validateSkillFrontmatter, DEFAULT_X402_MAX_INPUT_BYTES, resolveInsidePathReal, DEFAULT_SCRIPT_TIMEOUT_MS, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
21
24
  import { x402Client, wrapFetchWithPayment } from '@x402/fetch';
22
25
  import { ExactSvmScheme } from '@x402/svm';
23
- import { decodePaymentRequiredHeader } from '@x402/core/http';
24
- import chalk from 'chalk';
25
- import Decimal from 'decimal.js-light';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
28
  var __defProp = Object.defineProperty;
@@ -1102,14 +1102,14 @@ async function cmdInit(nameArg, options = {}) {
1102
1102
  if (options.config) {
1103
1103
  const configPath = resolve(cwd, options.config);
1104
1104
  const raw = readFileSync(configPath, "utf-8");
1105
- template = ElisymYamlSchema.parse(YAML.parse(raw) ?? {});
1105
+ template = ElisymYamlSchema.parse(YAML2.parse(raw) ?? {});
1106
1106
  console.log(` Loaded template from ${configPath}
1107
1107
  `);
1108
1108
  } else if (options.defaults) {
1109
1109
  template = buildDefaultYaml();
1110
1110
  console.log(" Using default skeleton (no LLM, no payments). Edit later via `profile`.\n");
1111
1111
  }
1112
- const agentName = await resolveAgentName(nameArg, inquirer);
1112
+ const agentName = await resolveAgentName2(nameArg, inquirer);
1113
1113
  const target = pickTarget(options);
1114
1114
  const sameLocation = target === "home" ? resolveInHome(agentName) : resolveInProject(agentName, cwd);
1115
1115
  if (sameLocation) {
@@ -1291,7 +1291,7 @@ async function cmdInit(nameArg, options = {}) {
1291
1291
  }
1292
1292
  console.log();
1293
1293
  }
1294
- async function resolveAgentName(nameArg, inquirer) {
1294
+ async function resolveAgentName2(nameArg, inquirer) {
1295
1295
  if (nameArg) {
1296
1296
  validateAgentName(nameArg);
1297
1297
  return nameArg;
@@ -1442,103 +1442,928 @@ var init_init = __esm({
1442
1442
  init_llm();
1443
1443
  }
1444
1444
  });
1445
- var IDENTITY_PLATFORMS = ["github", "x", "website"];
1446
- var X_PROOF_URL_HOSTS = /* @__PURE__ */ new Set([
1447
- "x.com",
1448
- "www.x.com",
1449
- "mobile.x.com",
1450
- "twitter.com",
1451
- "www.twitter.com",
1452
- "mobile.twitter.com"
1453
- ]);
1454
- function githubProofText(npub) {
1455
- return `Verifying that I control the following Nostr public key: ${npub}`;
1456
- }
1457
- function xProofText(npub) {
1458
- return `Verifying my account on nostr My Public Key: "${npub}"`;
1459
- }
1460
- function websiteNostrJson(local, pubkeyHex) {
1461
- return JSON.stringify({ names: { [local]: pubkeyHex } }, null, 2);
1462
- }
1463
- function parseIdentityPlatform(raw) {
1464
- const lowered = raw.trim().toLowerCase();
1465
- const match = IDENTITY_PLATFORMS.find((platform) => platform === lowered);
1466
- if (match === void 0) {
1467
- throw new Error(`Unknown platform "${raw}". Expected github, x, or website.`);
1445
+ var MAX_CONCURRENT_JOBS = 10;
1446
+ var RECOVERY_MAX_RETRIES = 5;
1447
+ var RECOVERY_INTERVAL_SECS = 60;
1448
+ var WATCHDOG_PROBE_INTERVAL_MS = 5 * 60 * 1e3;
1449
+ var WATCHDOG_PROBE_TIMEOUT_MS = 1e4;
1450
+ var WATCHDOG_SELF_PING_INTERVAL_MS = 10 * 60 * 1e3;
1451
+ var WATCHDOG_SELF_PING_TIMEOUT_MS = 15e3;
1452
+ var WATCHDOG_SLEEP_DETECT_MULTIPLIER = 2;
1453
+ function getRpcUrl(_network) {
1454
+ const envUrl = process.env.SOLANA_RPC_URL;
1455
+ if (envUrl) {
1456
+ return envUrl;
1468
1457
  }
1469
- return match;
1458
+ return "https://api.devnet.solana.com";
1470
1459
  }
1471
- function parseGithubProofInput(raw) {
1472
- const trimmed = raw.trim();
1473
- const bareId = trimmed.toLowerCase();
1474
- if (GIST_ID_REGEX.test(bareId)) {
1475
- return { proofId: bareId };
1460
+ async function fetchUsdcBalance(rpc, owner) {
1461
+ const mint = USDC_SOLANA_DEVNET.mint;
1462
+ if (!mint) {
1463
+ return 0n;
1476
1464
  }
1477
- let url;
1478
1465
  try {
1479
- url = new URL(trimmed);
1466
+ const response = await rpc.getTokenAccountsByOwner(
1467
+ owner,
1468
+ { mint: address(mint) },
1469
+ { encoding: "jsonParsed", commitment: "confirmed" }
1470
+ ).send();
1471
+ let total = 0n;
1472
+ for (const entry of response.value) {
1473
+ const parsed = entry.account.data;
1474
+ const raw = parsed?.parsed?.info?.tokenAmount?.amount;
1475
+ if (typeof raw === "string") {
1476
+ total += BigInt(raw);
1477
+ }
1478
+ }
1479
+ return total;
1480
1480
  } catch {
1481
- return null;
1481
+ return 0n;
1482
1482
  }
1483
- if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "gist.github.com") {
1483
+ }
1484
+
1485
+ // src/x402/constants.ts
1486
+ var X402_SOLANA_DEVNET_CAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";
1487
+ var X402_SOLANA_DEVNET_V1 = "solana-devnet";
1488
+ var X402_GET_INPUT_MAX_ENCODED_BYTES = 2048;
1489
+ var X402_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
1490
+ var X402_MAX_CHALLENGE_BYTES = 256 * 1024;
1491
+ var X402_PROBE_TTL_MS = 3e4;
1492
+ var X402_MAX_PAID_ATTEMPTS = 2;
1493
+ var X402_MAX_PAYMENT_SIGNATURES = 2 * X402_MAX_PAID_ATTEMPTS;
1494
+ var X402_FREE_RETRY_DELAYS_MS = [1e3, 3e3];
1495
+ var X402_REFUNDED_RETRIES = 1;
1496
+ var X402_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
1497
+
1498
+ // src/x402/matcher.ts
1499
+ function requirementAmount(requirement) {
1500
+ const v1Amount = requirement.maxAmountRequired;
1501
+ const raw = requirement.amount ?? v1Amount;
1502
+ if (typeof raw !== "string" || !/^[0-9]+$/.test(raw)) {
1484
1503
  return null;
1485
1504
  }
1486
- const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
1487
- const [userSegment, gistSegment] = segments;
1488
- if (userSegment === void 0 || gistSegment === void 0) {
1489
- return null;
1505
+ return BigInt(raw);
1506
+ }
1507
+ function isAcceptableRequirement(requirement, rule) {
1508
+ if (requirement.scheme !== "exact") {
1509
+ return false;
1490
1510
  }
1491
- const username = userSegment.toLowerCase();
1492
- const gistId = gistSegment.toLowerCase();
1493
- if (!GITHUB_USERNAME_REGEX.test(username) || !GIST_ID_REGEX.test(gistId)) {
1494
- return null;
1511
+ const network = requirement.network;
1512
+ if (network !== X402_SOLANA_DEVNET_CAIP2 && network !== X402_SOLANA_DEVNET_V1) {
1513
+ return false;
1495
1514
  }
1496
- return { proofId: gistId, username };
1515
+ if (USDC_SOLANA_DEVNET.mint === void 0 || requirement.asset !== USDC_SOLANA_DEVNET.mint) {
1516
+ return false;
1517
+ }
1518
+ const amount = requirementAmount(requirement);
1519
+ if (amount === null) {
1520
+ return false;
1521
+ }
1522
+ return amount <= rule.maxUpstreamSubunits;
1497
1523
  }
1498
- function parseXProofInput(raw) {
1499
- const trimmed = raw.trim();
1500
- if (TWEET_ID_REGEX.test(trimmed)) {
1501
- return { proofId: trimmed };
1524
+ function selectAcceptableRequirement(accepts, rule) {
1525
+ return accepts.find((requirement) => isAcceptableRequirement(requirement, rule));
1526
+ }
1527
+ function maxAcceptableQuote(accepts, rule) {
1528
+ let worst = null;
1529
+ for (const requirement of accepts) {
1530
+ if (!isAcceptableRequirement(requirement, rule)) {
1531
+ continue;
1532
+ }
1533
+ const amount = requirementAmount(requirement);
1534
+ if (amount === null) {
1535
+ continue;
1536
+ }
1537
+ if (worst === null || amount > worst) {
1538
+ worst = amount;
1539
+ }
1502
1540
  }
1503
- let url;
1541
+ return worst;
1542
+ }
1543
+ function buildRequirementsPolicy(rule) {
1544
+ return function filterAcceptableRequirements(_x402Version, paymentRequirements) {
1545
+ return paymentRequirements.filter((requirement) => isAcceptableRequirement(requirement, rule));
1546
+ };
1547
+ }
1548
+ var PROBE_TIMEOUT_MS = 1e4;
1549
+ var MAX_PROBE_BODY_BYTES = 256 * 1024;
1550
+ var PROBE_CACHE_MAX_ENTRIES = 256;
1551
+ async function readTextCapped(response, maxBytes) {
1552
+ const reader = response.body?.getReader();
1553
+ if (reader === void 0) {
1554
+ const buffer = await response.arrayBuffer();
1555
+ if (buffer.byteLength > maxBytes) {
1556
+ throw new X402ProbeError(`402 body exceeds ${maxBytes} bytes`, 402);
1557
+ }
1558
+ return new TextDecoder().decode(buffer);
1559
+ }
1560
+ const chunks = [];
1561
+ let total = 0;
1562
+ while (true) {
1563
+ const { done, value } = await reader.read();
1564
+ if (done) {
1565
+ break;
1566
+ }
1567
+ total += value.byteLength;
1568
+ if (total > maxBytes) {
1569
+ await reader.cancel().catch(() => {
1570
+ });
1571
+ throw new X402ProbeError(`402 body exceeds ${maxBytes} bytes`, 402);
1572
+ }
1573
+ chunks.push(value);
1574
+ }
1575
+ const combined = new Uint8Array(total);
1576
+ let offset = 0;
1577
+ for (const chunk of chunks) {
1578
+ combined.set(chunk, offset);
1579
+ offset += chunk.byteLength;
1580
+ }
1581
+ return new TextDecoder().decode(combined);
1582
+ }
1583
+ var X402ProbeError = class extends Error {
1584
+ status;
1585
+ constructor(message, status) {
1586
+ super(message);
1587
+ this.name = "X402ProbeError";
1588
+ this.status = status;
1589
+ }
1590
+ };
1591
+ function normalizeV1Requirement(raw) {
1592
+ return {
1593
+ scheme: typeof raw.scheme === "string" ? raw.scheme : "",
1594
+ network: typeof raw.network === "string" ? raw.network : "unknown:unknown",
1595
+ asset: typeof raw.asset === "string" ? raw.asset : "",
1596
+ payTo: typeof raw.payTo === "string" ? raw.payTo : "",
1597
+ amount: typeof raw.maxAmountRequired === "string" ? raw.maxAmountRequired : "",
1598
+ maxTimeoutSeconds: typeof raw.maxTimeoutSeconds === "number" ? raw.maxTimeoutSeconds : 60,
1599
+ extra: raw.extra ?? {}
1600
+ };
1601
+ }
1602
+ function parseV1Body(bodyText) {
1603
+ let parsed;
1504
1604
  try {
1505
- url = new URL(trimmed);
1605
+ parsed = JSON.parse(bodyText);
1506
1606
  } catch {
1507
1607
  return null;
1508
1608
  }
1509
- if (url.protocol !== "https:" || !X_PROOF_URL_HOSTS.has(url.hostname.toLowerCase())) {
1609
+ if (typeof parsed !== "object" || parsed === null) {
1510
1610
  return null;
1511
1611
  }
1512
- const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
1513
- const [userSegment, statusLiteral, idSegment] = segments;
1514
- if (userSegment === void 0 || statusLiteral !== "status" || idSegment === void 0) {
1612
+ const body = parsed;
1613
+ if (body.x402Version !== 1 || !Array.isArray(body.accepts) || body.accepts.length === 0) {
1515
1614
  return null;
1516
1615
  }
1517
- const username = userSegment.toLowerCase();
1518
- if (!X_USERNAME_REGEX.test(username) || !TWEET_ID_REGEX.test(idSegment)) {
1616
+ const rawRequirements = body.accepts.filter(
1617
+ (item) => typeof item === "object" && item !== null
1618
+ );
1619
+ if (rawRequirements.length === 0) {
1519
1620
  return null;
1520
1621
  }
1521
- return { proofId: idSegment, username };
1622
+ const accepts = rawRequirements.map(normalizeV1Requirement);
1623
+ const first = rawRequirements[0];
1624
+ return {
1625
+ x402Version: 1,
1626
+ accepts,
1627
+ resource: {
1628
+ url: typeof first.resource === "string" ? first.resource : void 0,
1629
+ description: typeof first.description === "string" ? first.description : void 0,
1630
+ mimeType: typeof first.mimeType === "string" ? first.mimeType : void 0
1631
+ }
1632
+ };
1522
1633
  }
1523
- function buildIdentityClaimInputs(identities) {
1524
- const claims = [];
1525
- if (identities?.github !== void 0) {
1526
- claims.push({
1527
- platform: "github",
1528
- handle: identities.github.username,
1529
- proofId: identities.github.gist
1634
+ async function probePaymentRequired(url, method, signal) {
1635
+ const probeSignal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
1636
+ const response = await fetch(url, { method, signal: probeSignal, redirect: "error" });
1637
+ if (response.status !== 402) {
1638
+ await response.body?.cancel().catch(() => {
1530
1639
  });
1640
+ throw new X402ProbeError(
1641
+ `expected HTTP 402 from ${url}, got ${response.status} - not an x402-paid endpoint (or it validates the request before the paywall; try the other method)`,
1642
+ response.status
1643
+ );
1644
+ }
1645
+ const headerValue = response.headers.get("PAYMENT-REQUIRED");
1646
+ if (headerValue !== null) {
1647
+ await response.body?.cancel().catch(() => {
1648
+ });
1649
+ try {
1650
+ const paymentRequired = decodePaymentRequiredHeader(headerValue);
1651
+ return {
1652
+ x402Version: paymentRequired.x402Version,
1653
+ accepts: paymentRequired.accepts,
1654
+ resource: paymentRequired.resource
1655
+ };
1656
+ } catch (error) {
1657
+ const message = error instanceof Error ? error.message : String(error);
1658
+ throw new X402ProbeError(`could not decode PAYMENT-REQUIRED header: ${message}`, 402);
1659
+ }
1660
+ }
1661
+ const bodyText = await readTextCapped(response, MAX_PROBE_BODY_BYTES).catch((error) => {
1662
+ if (error instanceof X402ProbeError) {
1663
+ throw error;
1664
+ }
1665
+ return "";
1666
+ });
1667
+ const v1 = parseV1Body(bodyText);
1668
+ if (v1 !== null) {
1669
+ return v1;
1670
+ }
1671
+ throw new X402ProbeError(
1672
+ "got HTTP 402 but neither a v2 PAYMENT-REQUIRED header nor a v1 JSON body was parseable",
1673
+ 402
1674
+ );
1675
+ }
1676
+ var PROBE_CACHE = /* @__PURE__ */ new Map();
1677
+ async function probePaymentRequiredCached(url, method, ttlMs) {
1678
+ const key = `${method} ${url}`;
1679
+ const cached = PROBE_CACHE.get(key);
1680
+ const now = Date.now();
1681
+ if (cached !== void 0 && now - cached.at < ttlMs) {
1682
+ return cached.result;
1683
+ }
1684
+ const result = await probePaymentRequired(url, method);
1685
+ PROBE_CACHE.delete(key);
1686
+ PROBE_CACHE.set(key, { at: now, result });
1687
+ while (PROBE_CACHE.size > PROBE_CACHE_MAX_ENTRIES) {
1688
+ const oldest = PROBE_CACHE.keys().next().value;
1689
+ if (oldest === void 0) {
1690
+ break;
1691
+ }
1692
+ PROBE_CACHE.delete(oldest);
1693
+ }
1694
+ return result;
1695
+ }
1696
+
1697
+ // src/commands/x402-add.ts
1698
+ var BPS_DENOMINATOR = 1e4;
1699
+ var DEFAULT_MARGIN_BPS = 1e3;
1700
+ var SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
1701
+ var CIRCLE_FAUCET_URL = "https://faucet.circle.com";
1702
+ function fail(message) {
1703
+ console.error(`
1704
+ ! ${message}
1705
+ `);
1706
+ process.exit(1);
1707
+ }
1708
+ function sanitizeUpstreamText(raw, maxLength) {
1709
+ let withoutControls = "";
1710
+ for (const char of raw) {
1711
+ const code = char.codePointAt(0) ?? 0;
1712
+ withoutControls += code < 32 || code === 127 ? " " : char;
1713
+ }
1714
+ return withoutControls.replace(/—/g, "-").replace(/\s+/g, " ").trim().slice(0, maxLength).trim();
1715
+ }
1716
+ function slugFromUrl(url) {
1717
+ const pathPart = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? "";
1718
+ const host = url.hostname.replace(/^www\./, "").split(".")[0] ?? "x402";
1719
+ return pathPart.length > 0 ? `${host}-${pathPart}` : host;
1720
+ }
1721
+ function parseMarginBps(raw) {
1722
+ if (raw === void 0) {
1723
+ return DEFAULT_MARGIN_BPS;
1724
+ }
1725
+ const value = Number(raw);
1726
+ if (!Number.isInteger(value) || value < 0 || value > 1e5) {
1727
+ fail(`--margin-bps must be a non-negative integer (bps, 100 = 1%); got "${raw}"`);
1728
+ }
1729
+ return value;
1730
+ }
1731
+ async function parseImportedSecret(input) {
1732
+ const trimmed = input.trim();
1733
+ if (existsSync(trimmed)) {
1734
+ const content = await readFile(trimmed, "utf-8");
1735
+ let parsed;
1736
+ try {
1737
+ parsed = JSON.parse(content);
1738
+ } catch {
1739
+ throw new Error(`${trimmed} is not a valid solana-keygen JSON file`);
1740
+ }
1741
+ if (!Array.isArray(parsed) || parsed.length !== 64 || parsed.some(
1742
+ (byte) => typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255
1743
+ )) {
1744
+ throw new Error(`${trimmed} must contain a JSON array of 64 bytes (solana-keygen format)`);
1745
+ }
1746
+ return encodeSecretKeyBase58(new Uint8Array(parsed));
1747
+ }
1748
+ try {
1749
+ await signerFromSecretKeyBase58(trimmed);
1750
+ } catch {
1751
+ throw new Error("not a readable file and not a valid base58-encoded 64-byte secret key");
1752
+ }
1753
+ return trimmed;
1754
+ }
1755
+ function computeBridgePriceSubunits(quote, marginBps, feeBps) {
1756
+ if (feeBps >= BPS_DENOMINATOR) {
1757
+ throw new Error(`feeBps ${feeBps} >= ${BPS_DENOMINATOR} - refusing zero/negative denominator`);
1758
+ }
1759
+ return BigInt(
1760
+ new Decimal(quote.toString()).mul(BPS_DENOMINATOR + marginBps).div(BPS_DENOMINATOR - feeBps).toDecimalPlaces(0, Decimal.ROUND_CEIL).toString()
1761
+ );
1762
+ }
1763
+ function buildSkillMd(input) {
1764
+ const frontmatter = {
1765
+ name: input.skillName,
1766
+ description: input.description,
1767
+ capabilities: input.capabilities,
1768
+ price: input.priceDisplay,
1769
+ token: "usdc",
1770
+ mode: "x402",
1771
+ x402_url: input.url,
1772
+ x402_method: input.method,
1773
+ ...input.queryParam !== void 0 ? { x402_query_param: input.queryParam } : {},
1774
+ x402_max_upstream: Number(input.quote),
1775
+ // GET input is dominated by the ~2KB percent-encoded query-string limit,
1776
+ // so the byte cap is written for POST bridges only.
1777
+ ...input.method === "POST" ? { x402_max_input_bytes: DEFAULT_X402_MAX_INPUT_BYTES } : {}
1778
+ };
1779
+ const bodyLines = [
1780
+ `# ${input.skillName}`,
1781
+ "",
1782
+ "Operator notes for this x402 bridge skill (the executor ignores this body):",
1783
+ "",
1784
+ `- Upstream: ${input.url} (${input.method})`,
1785
+ `- Upstream quote at generation time: ${formatAssetAmount(USDC_SOLANA_DEVNET, input.quote)} (= x402_max_upstream, the signing ceiling)`,
1786
+ `- Price: ${input.priceDisplay} USDC (margin ${input.marginPercent}%, protocol fee ${input.feePercent}% at generation time)`,
1787
+ "- To reprice after an upstream change, re-run:",
1788
+ "",
1789
+ "```bash",
1790
+ `npx @elisym/cli x402 add ${input.url}`,
1791
+ "```"
1792
+ ];
1793
+ return `---
1794
+ ${YAML2.stringify(frontmatter)}---
1795
+
1796
+ ${bodyLines.join("\n")}
1797
+ `;
1798
+ }
1799
+ function scanExistingSkills(skillsDir, candidateDTag, url) {
1800
+ const scan = {};
1801
+ let entries;
1802
+ try {
1803
+ entries = readdirSync(skillsDir);
1804
+ } catch {
1805
+ return scan;
1806
+ }
1807
+ for (const entry of entries) {
1808
+ const skillMdPath = join(skillsDir, entry, "SKILL.md");
1809
+ try {
1810
+ if (!statSync(join(skillsDir, entry)).isDirectory()) {
1811
+ continue;
1812
+ }
1813
+ const { frontmatter } = parseSkillMd(readFileSync(skillMdPath, "utf-8"));
1814
+ if (typeof frontmatter.name !== "string") {
1815
+ continue;
1816
+ }
1817
+ if (typeof frontmatter.x402_url === "string" && frontmatter.x402_url === url) {
1818
+ scan.sameUrlDir = join(skillsDir, entry);
1819
+ scan.sameUrlName = frontmatter.name;
1820
+ continue;
1821
+ }
1822
+ let existingDTag;
1823
+ try {
1824
+ existingDTag = toDTag(frontmatter.name);
1825
+ } catch {
1826
+ continue;
1827
+ }
1828
+ if (existingDTag === candidateDTag) {
1829
+ scan.dTagCollision = frontmatter.name;
1830
+ }
1831
+ } catch {
1832
+ continue;
1833
+ }
1834
+ }
1835
+ return scan;
1836
+ }
1837
+ function describeUnacceptableAccepts(probe) {
1838
+ const networks = [...new Set(probe.accepts.map((requirement) => String(requirement.network)))];
1839
+ if (networks.length > 0 && networks.every((network) => network.startsWith("eip155:"))) {
1840
+ return `the service accepts only EVM networks (${networks.join(", ")}) - not supported yet`;
1841
+ }
1842
+ if (networks.some((network) => network === SOLANA_MAINNET_CAIP2 || network === "solana")) {
1843
+ return "the service settles on Solana mainnet - elisym currently runs on devnet (mainnet is on the roadmap)";
1844
+ }
1845
+ return `no exact-scheme devnet-USDC requirement found (service accepts: ${networks.join(", ") || "nothing parseable"})`;
1846
+ }
1847
+ async function cmdX402Add(url, agentName, options) {
1848
+ const cwd = process.cwd();
1849
+ const { default: inquirer } = await import('inquirer');
1850
+ let parsedUrl;
1851
+ try {
1852
+ parsedUrl = new URL(url);
1853
+ } catch {
1854
+ fail(`"${url}" is not a valid URL`);
1855
+ }
1856
+ const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsedUrl.hostname);
1857
+ if (parsedUrl.protocol !== "https:" && !(parsedUrl.protocol === "http:" && isLoopback)) {
1858
+ fail("the upstream must be an https:// URL (plain http is allowed for localhost only)");
1859
+ }
1860
+ const methodRaw = (options.method ?? "POST").toUpperCase();
1861
+ if (methodRaw !== "GET" && methodRaw !== "POST") {
1862
+ fail(`--method must be GET or POST; got "${options.method}"`);
1863
+ }
1864
+ const method = methodRaw;
1865
+ const marginBps = parseMarginBps(options.marginBps);
1866
+ if (!agentName) {
1867
+ const agents = await listAgents(cwd);
1868
+ if (agents.length === 0) {
1869
+ fail("no agents found - create one first: npx @elisym/cli init");
1870
+ }
1871
+ if (options.yes) {
1872
+ fail("--yes needs an explicit agent argument: elisym x402 add <url> <agent> --yes");
1873
+ }
1874
+ const { selected } = await inquirer.prompt([
1875
+ {
1876
+ type: "list",
1877
+ name: "selected",
1878
+ message: "Add the bridge skill to which agent?",
1879
+ choices: agents.map((agent) => ({
1880
+ name: `${agent.name} (${agent.source})`,
1881
+ value: agent.name
1882
+ }))
1883
+ }
1884
+ ]);
1885
+ agentName = selected;
1886
+ }
1887
+ const passphrase = process.env.ELISYM_PASSPHRASE;
1888
+ const loaded = await loadAgent(agentName, cwd, passphrase);
1889
+ console.log(`
1890
+ Probing ${url} (${method}, unpaid)...`);
1891
+ let probe;
1892
+ try {
1893
+ probe = await probePaymentRequired(url, method);
1894
+ } catch (error) {
1895
+ if (error instanceof X402ProbeError) {
1896
+ fail(error.message);
1897
+ }
1898
+ const message = error instanceof Error ? error.message : String(error);
1899
+ fail(`upstream unreachable: ${message}`);
1900
+ }
1901
+ const anyCeiling = { maxUpstreamSubunits: BigInt(Number.MAX_SAFE_INTEGER) };
1902
+ const requirement = selectAcceptableRequirement(probe.accepts, anyCeiling);
1903
+ if (requirement === void 0) {
1904
+ fail(describeUnacceptableAccepts(probe));
1905
+ }
1906
+ const quote = requirementAmount(requirement);
1907
+ if (quote === null || quote <= 0n) {
1908
+ fail("the upstream quote is not a positive integer amount");
1909
+ }
1910
+ let solanaSecretKey = loaded.secrets.solana_secret_key;
1911
+ if (solanaSecretKey === void 0 || solanaSecretKey.length === 0) {
1912
+ let choice;
1913
+ if (options.yes || options.generateWallet) {
1914
+ if (!options.generateWallet) {
1915
+ fail(
1916
+ "this agent has no Solana wallet key. Run interactively to generate/import one, or pass --generate-wallet (a money-holding key is never created silently under --yes)."
1917
+ );
1918
+ }
1919
+ choice = "generate";
1920
+ } else {
1921
+ const { walletChoice } = await inquirer.prompt([
1922
+ {
1923
+ type: "list",
1924
+ name: "walletChoice",
1925
+ message: "The bridge pays the upstream from the agent wallet, but .secrets.json has no solana_secret_key:",
1926
+ choices: [
1927
+ { name: "Generate a new wallet", value: "generate" },
1928
+ { name: "Import an existing one (solana-keygen JSON file or base58)", value: "import" }
1929
+ ]
1930
+ }
1931
+ ]);
1932
+ choice = walletChoice;
1933
+ }
1934
+ if (choice === "generate") {
1935
+ const wallet = await generateSolanaWallet();
1936
+ solanaSecretKey = wallet.secretKeyBase58;
1937
+ } else {
1938
+ const { imported } = await inquirer.prompt([
1939
+ {
1940
+ // Masked: a pasted base58 secret key must not echo to the terminal
1941
+ // scrollback (matches every other secret prompt in the CLI). A file
1942
+ // path can still be pasted/drag-dropped under the mask.
1943
+ type: "password",
1944
+ mask: "*",
1945
+ name: "imported",
1946
+ message: "Path to a solana-keygen JSON file (or paste a base58 secret key):"
1947
+ }
1948
+ ]);
1949
+ try {
1950
+ solanaSecretKey = await parseImportedSecret(imported);
1951
+ } catch (error) {
1952
+ fail(error instanceof Error ? error.message : String(error));
1953
+ }
1954
+ }
1955
+ if (loaded.secrets.solana_delegate_secret_key) {
1956
+ const paymentSigner = await signerFromSecretKeyBase58(solanaSecretKey);
1957
+ const delegateSigner = await signerFromSecretKeyBase58(
1958
+ loaded.secrets.solana_delegate_secret_key
1959
+ );
1960
+ if (paymentSigner.address === delegateSigner.address) {
1961
+ fail(
1962
+ "the wallet key equals this agent delegate key (solana_delegate_secret_key). They must differ for blast-radius isolation - import a different wallet key or rotate the delegate key."
1963
+ );
1964
+ }
1965
+ }
1966
+ await writeSecrets(
1967
+ loaded.dir,
1968
+ { ...loaded.secrets, solana_secret_key: solanaSecretKey },
1969
+ passphrase
1970
+ );
1971
+ console.log(" Wallet key saved to .secrets.json");
1972
+ }
1973
+ const signer = await signerFromSecretKeyBase58(solanaSecretKey);
1974
+ const solPayment = loaded.yaml.payments.find((entry) => entry.chain === "solana");
1975
+ if (solPayment !== void 0 && solPayment.address !== signer.address) {
1976
+ fail(
1977
+ `wallet invariant violated: elisym.yaml payments[].address (${solPayment.address}) differs from the solana_secret_key address (${signer.address}).
1978
+ Revenue must land in the wallet the bridge spends from, or the float never refills.
1979
+ Fix: set payments[].address to the key address, or import the matching key.`
1980
+ );
1981
+ }
1982
+ if (solPayment === void 0) {
1983
+ console.log(
1984
+ ` elisym.yaml has no Solana payments entry; adding address ${signer.address} (devnet).`
1985
+ );
1986
+ await writeYaml(loaded.dir, {
1987
+ ...loaded.yaml,
1988
+ payments: [
1989
+ ...loaded.yaml.payments,
1990
+ { chain: "solana", network: "devnet", address: signer.address }
1991
+ ]
1992
+ });
1993
+ }
1994
+ const rpc = createSolanaRpc(getRpcUrl());
1995
+ const balance = await fetchUsdcBalance(rpc, address(signer.address));
1996
+ let feeBps;
1997
+ try {
1998
+ const protocolConfig = await getProtocolConfig(rpc, getProtocolProgramId("devnet"), {
1999
+ forceRefresh: true
2000
+ });
2001
+ feeBps = protocolConfig.feeBps;
2002
+ } catch (error) {
2003
+ const message = error instanceof Error ? error.message : String(error);
2004
+ fail(`could not read the on-chain protocol config (fee): ${message}`);
2005
+ }
2006
+ if (feeBps >= BPS_DENOMINATOR) {
2007
+ fail(
2008
+ `protocol fee is ${feeBps} bps (>= 100%) - price math would divide by zero or go negative`
2009
+ );
2010
+ }
2011
+ const priceSubunits = computeBridgePriceSubunits(quote, marginBps, feeBps);
2012
+ const priceDisplay = new Decimal(priceSubunits.toString()).div(new Decimal(10).pow(USDC_SOLANA_DEVNET.decimals)).toString();
2013
+ const protocolFee = calculateProtocolFee(Number(priceSubunits), feeBps);
2014
+ const netMargin = priceSubunits - BigInt(protocolFee) - quote;
2015
+ const fallbackName = slugFromUrl(parsedUrl);
2016
+ const rawName = options.name ?? probe.resource?.serviceName ?? fallbackName;
2017
+ const skillName = sanitizeUpstreamText(rawName, LIMITS.MAX_AGENT_NAME_LENGTH);
2018
+ if (!/[a-zA-Z0-9]/.test(skillName)) {
2019
+ fail(
2020
+ `the upstream service name ("${rawName}") has no ASCII letters or digits - pass an explicit --name <skill-name>`
2021
+ );
2022
+ }
2023
+ const candidateDTag = toDTag(skillName);
2024
+ const description = sanitizeUpstreamText(
2025
+ probe.resource?.description ?? `x402 bridge to ${parsedUrl.hostname}`,
2026
+ LIMITS.MAX_DESCRIPTION_LENGTH
2027
+ );
2028
+ const capabilities = [candidateDTag];
2029
+ const skillsDir = join(loaded.dir, "skills");
2030
+ const scan = scanExistingSkills(skillsDir, candidateDTag, url);
2031
+ let targetDir = join(skillsDir, candidateDTag);
2032
+ if (scan.sameUrlDir !== void 0) {
2033
+ if (!options.yes) {
2034
+ const { update } = await inquirer.prompt([
2035
+ {
2036
+ type: "confirm",
2037
+ name: "update",
2038
+ message: `Skill "${scan.sameUrlName}" already bridges this URL - regenerate it with the fresh quote?`,
2039
+ default: true
2040
+ }
2041
+ ]);
2042
+ if (!update) {
2043
+ fail("aborted");
2044
+ }
2045
+ }
2046
+ targetDir = scan.sameUrlDir;
2047
+ } else if (scan.dTagCollision !== void 0) {
2048
+ fail(
2049
+ `the generated name "${skillName}" collides with existing skill "${scan.dTagCollision}" (both map to d-tag "${candidateDTag}", which would replace its live discovery card) - pass a distinct --name`
2050
+ );
2051
+ } else if (existsSync(targetDir)) {
2052
+ fail(`skill directory ${targetDir} already exists - pass a distinct --name`);
2053
+ }
2054
+ let queryParam = options.queryParam;
2055
+ if (method === "GET" && queryParam === void 0 && !options.yes) {
2056
+ const { param } = await inquirer.prompt([
2057
+ {
2058
+ type: "input",
2059
+ name: "param",
2060
+ message: "GET upstream: which query parameter should carry the buyer input? (empty = the skill takes no input)"
2061
+ }
2062
+ ]);
2063
+ queryParam = param.trim() === "" ? void 0 : param.trim();
2064
+ }
2065
+ if (queryParam !== void 0 && parsedUrl.searchParams.has(queryParam)) {
2066
+ fail(`--query-param "${queryParam}" collides with a parameter already present in the URL`);
2067
+ }
2068
+ if (method === "POST" && queryParam !== void 0) {
2069
+ fail("--query-param only applies to GET upstreams (POST maps the input to the request body)");
2070
+ }
2071
+ const noInput = method === "GET" && queryParam === void 0;
2072
+ const feePercent = new Decimal(feeBps).div(100).toString();
2073
+ const marginPercent = new Decimal(marginBps).div(100).toString();
2074
+ console.log("");
2075
+ console.log(` ${chalk.bold(skillName)}`);
2076
+ console.log(` ${description}`);
2077
+ console.log("");
2078
+ console.log(
2079
+ ` Upstream ${url} (${method}${queryParam !== void 0 ? `, input in ?${queryParam}=` : ""}${noInput ? ", no buyer input" : ""})`
2080
+ );
2081
+ console.log(` Quote ${formatAssetAmount(USDC_SOLANA_DEVNET, quote)}`);
2082
+ console.log(
2083
+ ` Your price ${formatAssetAmount(USDC_SOLANA_DEVNET, priceSubunits)} (margin ${marginPercent}% + protocol fee ${feePercent}%)`
2084
+ );
2085
+ console.log(` Net margin ${formatAssetAmount(USDC_SOLANA_DEVNET, netMargin)} per job`);
2086
+ console.log(
2087
+ ` Float ${formatAssetAmount(USDC_SOLANA_DEVNET, balance)} in ${signer.address}`
2088
+ );
2089
+ if (balance < quote) {
2090
+ console.log(
2091
+ chalk.yellow(
2092
+ ` ! Float below one upstream quote - fund the wallet with devnet USDC: ${CIRCLE_FAUCET_URL}`
2093
+ )
2094
+ );
2095
+ }
2096
+ console.log(` Skill dir ${targetDir}`);
2097
+ console.log("");
2098
+ if (!options.yes) {
2099
+ const { confirmed } = await inquirer.prompt([
2100
+ { type: "confirm", name: "confirmed", message: "Write the skill?", default: true }
2101
+ ]);
2102
+ if (!confirmed) {
2103
+ fail("aborted");
2104
+ }
2105
+ }
2106
+ const content = buildSkillMd({
2107
+ skillName,
2108
+ description,
2109
+ capabilities,
2110
+ priceDisplay,
2111
+ url,
2112
+ method,
2113
+ queryParam,
2114
+ quote,
2115
+ marginPercent,
2116
+ feePercent
2117
+ });
2118
+ const roundTrip = parseSkillMd(content);
2119
+ validateSkillFrontmatter(roundTrip.frontmatter, roundTrip.systemPrompt, {
2120
+ allowFreeSkills: false,
2121
+ allowX402Skills: true
2122
+ });
2123
+ await mkdir(targetDir, { recursive: true });
2124
+ await writeFile(join(targetDir, "SKILL.md"), content, "utf-8");
2125
+ await ensureGitignoreHasX402Entries(dirname(loaded.dir));
2126
+ console.log(`
2127
+ Wrote ${join(targetDir, "SKILL.md")}`);
2128
+ if (balance < quote) {
2129
+ console.log(` Next: fund the float (devnet USDC): ${CIRCLE_FAUCET_URL}`);
2130
+ }
2131
+ console.log(` Start the agent: npx @elisym/cli start ${agentName}
2132
+ `);
2133
+ }
2134
+
2135
+ // src/commands/delegate-key.ts
2136
+ async function resolveAgentName(name, cwd, nonInteractive) {
2137
+ if (name) {
2138
+ return name;
2139
+ }
2140
+ if (nonInteractive) {
2141
+ console.error("--yes needs an explicit agent argument: elisym delegate-key <agent> --yes");
2142
+ process.exit(1);
2143
+ }
2144
+ const agents = await listAgents(cwd);
2145
+ if (agents.length === 0) {
2146
+ console.error("No agents found.");
2147
+ process.exit(1);
2148
+ }
2149
+ const { default: inquirer } = await import('inquirer');
2150
+ const { selected } = await inquirer.prompt([
2151
+ {
2152
+ type: "list",
2153
+ name: "selected",
2154
+ message: "Select agent:",
2155
+ choices: agents.map((agent) => ({
2156
+ name: `${agent.name} (${agent.source})`,
2157
+ value: agent.name
2158
+ }))
2159
+ }
2160
+ ]);
2161
+ return selected;
2162
+ }
2163
+ async function printDelegateStatus(delegateSecretKey, network) {
2164
+ const signer = await signerFromSecretKeyBase58(delegateSecretKey);
2165
+ const rpc = createSolanaRpc(getRpcUrl());
2166
+ let gasLine = "";
2167
+ try {
2168
+ const { value: balance } = await rpc.getBalance(address(signer.address)).send();
2169
+ gasLine = ` SOL (gas): ${formatSol(Number(balance))} (${balance} lamports)`;
2170
+ } catch {
2171
+ gasLine = " SOL (gas): <balance unavailable>";
2172
+ }
2173
+ console.log(`
2174
+ Delegate key (spl-approve):`);
2175
+ console.log(` Pubkey: ${signer.address}`);
2176
+ console.log(gasLine);
2177
+ console.log(
2178
+ `
2179
+ Fund this address with a small amount of SOL for transaction gas.
2180
+ It is published on this agent's capability cards that declare delegation.
2181
+ `
2182
+ );
2183
+ }
2184
+ async function cmdDelegateKey(name, options) {
2185
+ const cwd = process.cwd();
2186
+ const agentName = await resolveAgentName(name, cwd, options.yes === true);
2187
+ const passphrase = process.env.ELISYM_PASSPHRASE;
2188
+ const loaded = await loadAgent(agentName, cwd, passphrase);
2189
+ loaded.yaml.payments.find((entry) => entry.chain === "solana")?.network ?? "devnet";
2190
+ const existing = loaded.secrets.solana_delegate_secret_key;
2191
+ if (options.show) {
2192
+ if (!existing) {
2193
+ console.error(
2194
+ `Agent "${agentName}" has no delegate key. Run \`elisym delegate-key ${agentName}\` to create one.`
2195
+ );
2196
+ process.exit(1);
2197
+ }
2198
+ await printDelegateStatus(existing);
2199
+ return;
2200
+ }
2201
+ if (existing && !options.rotate) {
2202
+ console.log(` Agent "${agentName}" already has a delegate key.`);
2203
+ await printDelegateStatus(existing);
2204
+ console.log(" Use --rotate to replace it (this invalidates outstanding owner approvals).");
2205
+ return;
2206
+ }
2207
+ const nonInteractive = options.yes === true;
2208
+ if (options.rotate && existing && !nonInteractive) {
2209
+ const { default: inquirer } = await import('inquirer');
2210
+ const { confirmRotate } = await inquirer.prompt([
2211
+ {
2212
+ type: "confirm",
2213
+ name: "confirmRotate",
2214
+ default: false,
2215
+ message: "Rotating the delegate key invalidates every outstanding owner approval (owners must re-approve the new key). Continue?"
2216
+ }
2217
+ ]);
2218
+ if (!confirmRotate) {
2219
+ console.log(" Aborted. Delegate key unchanged.");
2220
+ return;
2221
+ }
2222
+ }
2223
+ let delegateSecretKey;
2224
+ if (options.import) {
2225
+ if (nonInteractive) {
2226
+ console.error(
2227
+ "Import is interactive (it prompts for the key). Re-run without --yes to import."
2228
+ );
2229
+ process.exit(1);
2230
+ }
2231
+ const { default: inquirer } = await import('inquirer');
2232
+ const { imported } = await inquirer.prompt([
2233
+ {
2234
+ type: "password",
2235
+ mask: "*",
2236
+ name: "imported",
2237
+ message: "Path to a solana-keygen JSON file (or paste a base58 secret key):"
2238
+ }
2239
+ ]);
2240
+ try {
2241
+ delegateSecretKey = await parseImportedSecret(imported);
2242
+ } catch (error) {
2243
+ console.error(error instanceof Error ? error.message : String(error));
2244
+ process.exit(1);
2245
+ }
2246
+ } else {
2247
+ const wallet = await generateSolanaWallet();
2248
+ delegateSecretKey = wallet.secretKeyBase58;
2249
+ }
2250
+ if (loaded.secrets.solana_secret_key) {
2251
+ const paymentSigner = await signerFromSecretKeyBase58(loaded.secrets.solana_secret_key);
2252
+ const delegateSigner = await signerFromSecretKeyBase58(delegateSecretKey);
2253
+ if (paymentSigner.address === delegateSigner.address) {
2254
+ console.error(
2255
+ "The delegate key must differ from the agent payment key (solana_secret_key) for blast-radius isolation."
2256
+ );
2257
+ process.exit(1);
2258
+ }
2259
+ }
2260
+ await writeSecrets(
2261
+ loaded.dir,
2262
+ { ...loaded.secrets, solana_delegate_secret_key: delegateSecretKey },
2263
+ passphrase
2264
+ );
2265
+ console.log(
2266
+ existing ? "\n Delegate key rotated and saved." : "\n Delegate key generated and saved."
2267
+ );
2268
+ await printDelegateStatus(delegateSecretKey);
2269
+ }
2270
+ var IDENTITY_PLATFORMS = ["github", "x", "website"];
2271
+ var X_PROOF_URL_HOSTS = /* @__PURE__ */ new Set([
2272
+ "x.com",
2273
+ "www.x.com",
2274
+ "mobile.x.com",
2275
+ "twitter.com",
2276
+ "www.twitter.com",
2277
+ "mobile.twitter.com"
2278
+ ]);
2279
+ function githubProofText(npub) {
2280
+ return `Verifying that I control the following Nostr public key: ${npub}`;
2281
+ }
2282
+ function xProofText(npub) {
2283
+ return `Verifying my account on nostr My Public Key: "${npub}"`;
2284
+ }
2285
+ function websiteNostrJson(local, pubkeyHex) {
2286
+ return JSON.stringify({ names: { [local]: pubkeyHex } }, null, 2);
2287
+ }
2288
+ function parseIdentityPlatform(raw) {
2289
+ const lowered = raw.trim().toLowerCase();
2290
+ const match = IDENTITY_PLATFORMS.find((platform) => platform === lowered);
2291
+ if (match === void 0) {
2292
+ throw new Error(`Unknown platform "${raw}". Expected github, x, or website.`);
2293
+ }
2294
+ return match;
2295
+ }
2296
+ function parseGithubProofInput(raw) {
2297
+ const trimmed = raw.trim();
2298
+ const bareId = trimmed.toLowerCase();
2299
+ if (GIST_ID_REGEX.test(bareId)) {
2300
+ return { proofId: bareId };
2301
+ }
2302
+ let url;
2303
+ try {
2304
+ url = new URL(trimmed);
2305
+ } catch {
2306
+ return null;
2307
+ }
2308
+ if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "gist.github.com") {
2309
+ return null;
2310
+ }
2311
+ const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
2312
+ const [userSegment, gistSegment] = segments;
2313
+ if (userSegment === void 0 || gistSegment === void 0) {
2314
+ return null;
2315
+ }
2316
+ const username = userSegment.toLowerCase();
2317
+ const gistId = gistSegment.toLowerCase();
2318
+ if (!GITHUB_USERNAME_REGEX.test(username) || !GIST_ID_REGEX.test(gistId)) {
2319
+ return null;
2320
+ }
2321
+ return { proofId: gistId, username };
2322
+ }
2323
+ function parseXProofInput(raw) {
2324
+ const trimmed = raw.trim();
2325
+ if (TWEET_ID_REGEX.test(trimmed)) {
2326
+ return { proofId: trimmed };
2327
+ }
2328
+ let url;
2329
+ try {
2330
+ url = new URL(trimmed);
2331
+ } catch {
2332
+ return null;
2333
+ }
2334
+ if (url.protocol !== "https:" || !X_PROOF_URL_HOSTS.has(url.hostname.toLowerCase())) {
2335
+ return null;
2336
+ }
2337
+ const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
2338
+ const [userSegment, statusLiteral, idSegment] = segments;
2339
+ if (userSegment === void 0 || statusLiteral !== "status" || idSegment === void 0) {
2340
+ return null;
2341
+ }
2342
+ const username = userSegment.toLowerCase();
2343
+ if (!X_USERNAME_REGEX.test(username) || !TWEET_ID_REGEX.test(idSegment)) {
2344
+ return null;
2345
+ }
2346
+ return { proofId: idSegment, username };
2347
+ }
2348
+ function buildIdentityClaimInputs(identities) {
2349
+ const claims = [];
2350
+ if (identities?.github !== void 0) {
2351
+ claims.push({
2352
+ platform: "github",
2353
+ handle: identities.github.username,
2354
+ proofId: identities.github.gist
2355
+ });
2356
+ }
2357
+ if (identities?.x !== void 0) {
2358
+ claims.push({ platform: "x", handle: identities.x.username, proofId: identities.x.tweet });
2359
+ }
2360
+ return claims;
2361
+ }
2362
+ function identitiesFromYaml(identities) {
2363
+ const claims = [];
2364
+ if (identities === void 0) {
2365
+ return claims;
1531
2366
  }
1532
- if (identities?.x !== void 0) {
1533
- claims.push({ platform: "x", handle: identities.x.username, proofId: identities.x.tweet });
1534
- }
1535
- return claims;
1536
- }
1537
- function identitiesFromYaml(identities) {
1538
- const claims = [];
1539
- if (identities === void 0) {
1540
- return claims;
1541
- }
1542
2367
  if (identities.github !== void 0) {
1543
2368
  claims.push({
1544
2369
  platform: "github",
@@ -2262,74 +3087,35 @@ async function probeRelays(relays, logger, timeoutMs = TCP_PROBE_TIMEOUT_MS) {
2262
3087
  continue;
2263
3088
  }
2264
3089
  const { host, port } = parsed;
2265
- let ips = [];
2266
- try {
2267
- const resolved = await lookup(host, { all: true });
2268
- ips = resolved.map((entry) => entry.address);
2269
- } catch (error) {
2270
- const message = error instanceof Error ? error.message : String(error);
2271
- logger.debug(
2272
- { event: "net_diag_dns_failed", url, host, error: message },
2273
- "DNS lookup failed"
2274
- );
2275
- results.push({ url, host, port, ips: [], error: `dns: ${message}` });
2276
- continue;
2277
- }
2278
- const probe = await tcpProbe(host, port, timeoutMs);
2279
- if ("openMs" in probe) {
2280
- logger.debug(
2281
- { event: "net_diag_tcp_open", url, host, port, ips, tcpOpenMs: probe.openMs },
2282
- "TCP open"
2283
- );
2284
- results.push({ url, host, port, ips, tcpOpenMs: probe.openMs });
2285
- } else {
2286
- logger.debug(
2287
- { event: "net_diag_tcp_failed", url, host, port, ips, error: probe.error },
2288
- "TCP probe failed"
2289
- );
2290
- results.push({ url, host, port, ips, error: `tcp: ${probe.error}` });
2291
- }
2292
- }
2293
- return results;
2294
- }
2295
- var MAX_CONCURRENT_JOBS = 10;
2296
- var RECOVERY_MAX_RETRIES = 5;
2297
- var RECOVERY_INTERVAL_SECS = 60;
2298
- var WATCHDOG_PROBE_INTERVAL_MS = 5 * 60 * 1e3;
2299
- var WATCHDOG_PROBE_TIMEOUT_MS = 1e4;
2300
- var WATCHDOG_SELF_PING_INTERVAL_MS = 10 * 60 * 1e3;
2301
- var WATCHDOG_SELF_PING_TIMEOUT_MS = 15e3;
2302
- var WATCHDOG_SLEEP_DETECT_MULTIPLIER = 2;
2303
- function getRpcUrl(_network) {
2304
- const envUrl = process.env.SOLANA_RPC_URL;
2305
- if (envUrl) {
2306
- return envUrl;
2307
- }
2308
- return "https://api.devnet.solana.com";
2309
- }
2310
- async function fetchUsdcBalance(rpc, owner) {
2311
- const mint = USDC_SOLANA_DEVNET.mint;
2312
- if (!mint) {
2313
- return 0n;
2314
- }
2315
- try {
2316
- const response = await rpc.getTokenAccountsByOwner(
2317
- owner,
2318
- { mint: address(mint) },
2319
- { encoding: "jsonParsed", commitment: "confirmed" }
2320
- ).send();
2321
- let total = 0n;
2322
- for (const entry of response.value) {
2323
- const parsed = entry.account.data;
2324
- const raw = parsed?.parsed?.info?.tokenAmount?.amount;
2325
- if (typeof raw === "string") {
2326
- total += BigInt(raw);
2327
- }
3090
+ let ips = [];
3091
+ try {
3092
+ const resolved = await lookup(host, { all: true });
3093
+ ips = resolved.map((entry) => entry.address);
3094
+ } catch (error) {
3095
+ const message = error instanceof Error ? error.message : String(error);
3096
+ logger.debug(
3097
+ { event: "net_diag_dns_failed", url, host, error: message },
3098
+ "DNS lookup failed"
3099
+ );
3100
+ results.push({ url, host, port, ips: [], error: `dns: ${message}` });
3101
+ continue;
3102
+ }
3103
+ const probe = await tcpProbe(host, port, timeoutMs);
3104
+ if ("openMs" in probe) {
3105
+ logger.debug(
3106
+ { event: "net_diag_tcp_open", url, host, port, ips, tcpOpenMs: probe.openMs },
3107
+ "TCP open"
3108
+ );
3109
+ results.push({ url, host, port, ips, tcpOpenMs: probe.openMs });
3110
+ } else {
3111
+ logger.debug(
3112
+ { event: "net_diag_tcp_failed", url, host, port, ips, error: probe.error },
3113
+ "TCP probe failed"
3114
+ );
3115
+ results.push({ url, host, port, ips, error: `tcp: ${probe.error}` });
2328
3116
  }
2329
- return total;
2330
- } catch {
2331
- return 0n;
2332
3117
  }
3118
+ return results;
2333
3119
  }
2334
3120
  var LEDGER_DIR_MODE = 448;
2335
3121
  var LEDGER_FILE_MODE = 384;
@@ -2398,6 +3184,40 @@ var JobLedger = class {
2398
3184
  this.flush();
2399
3185
  }
2400
3186
  }
3187
+ /** Flush the delegated discriminator (called inside the atomic nonce mark). */
3188
+ markDelegated(jobId) {
3189
+ const entry = this.entries.get(jobId);
3190
+ if (entry) {
3191
+ entry.delegated = true;
3192
+ this.flush();
3193
+ }
3194
+ }
3195
+ /**
3196
+ * Persist a delegated job's result text BEFORE the pull, while status stays
3197
+ * `paid`. MUST be flushed strictly before `recordPullSignature` so a
3198
+ * persisted pull signature always implies the result is recoverable.
3199
+ */
3200
+ recordDeliveredContent(jobId, deliveredContent) {
3201
+ const entry = this.entries.get(jobId);
3202
+ if (entry) {
3203
+ entry.delivered_content = deliveredContent;
3204
+ this.flush();
3205
+ }
3206
+ }
3207
+ /**
3208
+ * Phase-A persist of the two-phase pull: the signature (durable idempotency
3209
+ * key), its blockhash terminal bound, and the amount the pull moves - one
3210
+ * flush, BEFORE any bytes hit the network.
3211
+ */
3212
+ recordPullSignature(jobId, pullSignature, pullLastValidBlockHeight, netAmount) {
3213
+ const entry = this.entries.get(jobId);
3214
+ if (entry) {
3215
+ entry.pull_signature = pullSignature;
3216
+ entry.pull_last_valid_block_height = pullLastValidBlockHeight;
3217
+ entry.net_amount = netAmount;
3218
+ this.flush();
3219
+ }
3220
+ }
2401
3221
  /** Attempt a state transition. Returns the entry if valid, undefined otherwise. */
2402
3222
  transition(jobId, to) {
2403
3223
  const entry = this.entries.get(jobId);
@@ -2438,6 +3258,7 @@ var JobLedger = class {
2438
3258
  const entry = this.transition(jobId, "delivered");
2439
3259
  if (entry) {
2440
3260
  entry.result = void 0;
3261
+ entry.delivered_content = void 0;
2441
3262
  this.flush();
2442
3263
  }
2443
3264
  }
@@ -2445,6 +3266,7 @@ var JobLedger = class {
2445
3266
  const entry = this.transition(jobId, "failed");
2446
3267
  if (entry) {
2447
3268
  entry.result = void 0;
3269
+ entry.delivered_content = void 0;
2448
3270
  try {
2449
3271
  this.flush();
2450
3272
  } catch {
@@ -2464,6 +3286,10 @@ var JobLedger = class {
2464
3286
  pendingJobs() {
2465
3287
  return [...this.entries.values()].filter((e) => e.status === "paid" || e.status === "executed");
2466
3288
  }
3289
+ /** Every entry, any status. Used to reconcile the used-nonce set on restart. */
3290
+ allEntries() {
3291
+ return [...this.entries.values()];
3292
+ }
2467
3293
  /** Remove old delivered/failed entries (default: 7 days). */
2468
3294
  gc(maxAgeSecs = 7 * 24 * 60 * 60) {
2469
3295
  this.pruneOldEntries(maxAgeSecs * 1e3);
@@ -2491,6 +3317,92 @@ var JobLedger = class {
2491
3317
  return deleted;
2492
3318
  }
2493
3319
  };
3320
+ var NONCE_STORE_MAX_ENTRIES = 1e4;
3321
+ var UsedNonceStore = class {
3322
+ /** key -> retain-until (unix seconds). Map order doubles as mark order for evict-oldest. */
3323
+ entries = /* @__PURE__ */ new Map();
3324
+ path;
3325
+ maxEntries;
3326
+ constructor(noncePath, maxEntries = NONCE_STORE_MAX_ENTRIES) {
3327
+ this.path = noncePath;
3328
+ this.maxEntries = maxEntries;
3329
+ this.load();
3330
+ }
3331
+ load() {
3332
+ try {
3333
+ const raw = readFileSync(this.path, "utf-8");
3334
+ const data = JSON.parse(raw);
3335
+ for (const [key, retainUntil] of Object.entries(data)) {
3336
+ if (typeof retainUntil === "number" && Number.isFinite(retainUntil)) {
3337
+ this.entries.set(key, retainUntil);
3338
+ }
3339
+ }
3340
+ } catch (e) {
3341
+ if (e?.code !== "ENOENT") {
3342
+ console.warn(` ! Nonce store load warning: ${e?.message ?? "unknown error"}`);
3343
+ try {
3344
+ const backupPath = this.path + ".corrupt." + Date.now();
3345
+ renameSync(this.path, backupPath);
3346
+ chmodSync(backupPath, LEDGER_FILE_MODE);
3347
+ } catch {
3348
+ }
3349
+ }
3350
+ }
3351
+ }
3352
+ flush() {
3353
+ const dir = dirname(this.path);
3354
+ mkdirSync(dir, { recursive: true, mode: LEDGER_DIR_MODE });
3355
+ const obj = Object.fromEntries(this.entries);
3356
+ const tmp = this.path + ".tmp";
3357
+ writeFileSync(tmp, JSON.stringify(obj), { mode: LEDGER_FILE_MODE });
3358
+ renameSync(tmp, this.path);
3359
+ chmodSync(this.path, LEDGER_FILE_MODE);
3360
+ }
3361
+ has(key) {
3362
+ return this.entries.has(key);
3363
+ }
3364
+ /**
3365
+ * Burn a nonce. Flushes by default; pass `persist: false` during bulk
3366
+ * restart reconciliation and call {@link flush} once at the end. Evicts the
3367
+ * oldest entry on overflow rather than rejecting.
3368
+ */
3369
+ markUsed(key, retainUntilSecs, opts = {}) {
3370
+ while (this.entries.size >= this.maxEntries) {
3371
+ const oldest = this.entries.keys().next();
3372
+ if (oldest.done) {
3373
+ break;
3374
+ }
3375
+ this.entries.delete(oldest.value);
3376
+ }
3377
+ this.entries.set(key, retainUntilSecs);
3378
+ if (opts.persist !== false) {
3379
+ try {
3380
+ this.flush();
3381
+ } catch {
3382
+ }
3383
+ }
3384
+ }
3385
+ /** Drop entries past their retain-until. Returns the number pruned. */
3386
+ prune(nowSecs = Math.floor(Date.now() / 1e3)) {
3387
+ let pruned = 0;
3388
+ for (const [key, retainUntil] of this.entries) {
3389
+ if (retainUntil <= nowSecs) {
3390
+ this.entries.delete(key);
3391
+ pruned += 1;
3392
+ }
3393
+ }
3394
+ if (pruned > 0) {
3395
+ try {
3396
+ this.flush();
3397
+ } catch {
3398
+ }
3399
+ }
3400
+ return pruned;
3401
+ }
3402
+ size() {
3403
+ return this.entries.size;
3404
+ }
3405
+ };
2494
3406
 
2495
3407
  // src/commands/start.ts
2496
3408
  init_llm();
@@ -3532,6 +4444,8 @@ function resolveHealthPair(skill) {
3532
4444
  }
3533
4445
  return null;
3534
4446
  }
4447
+ var DELEGATED_REJECTED_PREFIX = "Delegated payment rejected";
4448
+ var RECONCILE_RECHECK_ATTEMPTS = 6;
3535
4449
  var RATE_LIMIT_WINDOW_MS = 10 * 60 * 1e3;
3536
4450
  var FREE_MAX_JOBS_PER_CUSTOMER = 20;
3537
4451
  var FREE_GLOBAL_MAX_JOBS_PER_WINDOW = 200;
@@ -3540,7 +4454,7 @@ var PAID_GLOBAL_MAX_JOBS_PER_WINDOW = 2e3;
3540
4454
  var MAX_TRACKED_CUSTOMERS = 1e3;
3541
4455
  var GLOBAL_LIMITER_KEY = "__global__";
3542
4456
  var AgentRuntime = class {
3543
- constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport, identity2, blossomTransport, sessionStore) {
4457
+ constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport, identity2, blossomTransport, sessionStore, nonceStore) {
3544
4458
  this.transport = transport;
3545
4459
  this.skills = skills;
3546
4460
  this.skillCtx = skillCtx;
@@ -3552,6 +4466,7 @@ var AgentRuntime = class {
3552
4466
  this.identity = identity2;
3553
4467
  this.blossomTransport = blossomTransport;
3554
4468
  this.sessionStore = sessionStore;
4469
+ this.nonceStore = nonceStore;
3555
4470
  this.limit = pLimit(config.maxConcurrentJobs);
3556
4471
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
3557
4472
  }
@@ -3599,6 +4514,13 @@ var AgentRuntime = class {
3599
4514
  * the operator's API key.
3600
4515
  */
3601
4516
  freeLlmLimiters = createFreeLlmLimiterSet();
4517
+ /**
4518
+ * Per-owner in-flight delegated reservations (subunits). A concurrency gate,
4519
+ * NOT a budget: released on success AND failure (unlike the MCP session-spend
4520
+ * reservation, which sticks on success). Process-local by design - the
4521
+ * on-chain `delegated_amount` is the real ceiling.
4522
+ */
4523
+ delegationInFlight = /* @__PURE__ */ new Map();
3602
4524
  /**
3603
4525
  * Whether a job takes the session path: session ref present (transport only
3604
4526
  * populates it for NIP-44-encrypted requests) AND the matched skill opted in
@@ -3840,6 +4762,7 @@ var AgentRuntime = class {
3840
4762
  const log = this.callbacks.onLog ?? console.log;
3841
4763
  this.ledger.pruneOldEntries(LEDGER_RETENTION_MS);
3842
4764
  this.sessionStore?.init();
4765
+ this.reconcileUsedNonces();
3843
4766
  await this.recoverPendingJobs();
3844
4767
  this.recoveryInterval = setInterval(
3845
4768
  () => this.recoverPendingJobs().catch((e) => log(`Recovery error: ${e}`)),
@@ -3856,6 +4779,11 @@ var AgentRuntime = class {
3856
4779
  } catch (e) {
3857
4780
  log(`Session GC error: ${e.message}`);
3858
4781
  }
4782
+ try {
4783
+ this.nonceStore?.prune();
4784
+ } catch (e) {
4785
+ log(`Nonce GC error: ${e.message}`);
4786
+ }
3859
4787
  this.cleanupRateLimits();
3860
4788
  if (!this.transport.isHealthy()) {
3861
4789
  log("Warning: no events received in 30+ minutes. Check relay connectivity.");
@@ -4112,120 +5040,476 @@ var AgentRuntime = class {
4112
5040
  raw_event_json: JSON.stringify(job.rawEvent),
4113
5041
  created_at: Math.floor(Date.now() / 1e3)
4114
5042
  });
4115
- if (jobPrice > 0) {
5043
+ const claimsDelegated = job.delegated !== void 0 || job.delegatedRejected !== void 0;
5044
+ let delegatedPull;
5045
+ if (claimsDelegated) {
5046
+ delegatedPull = await this.delegatedPreCheck(job, matched, log);
5047
+ if (delegatedPull === void 0) {
5048
+ return;
5049
+ }
5050
+ netAmount = Number(delegatedPull.priceSubunits);
5051
+ } else if (jobPrice > 0) {
4116
5052
  const result = await this.collectPayment(job, jobPrice, jobAsset, signal);
4117
5053
  netAmount = result.netAmount;
4118
5054
  paymentRequest = result.paymentRequest;
4119
5055
  this.ledger.updatePayment(job.jobId, netAmount, paymentRequest);
4120
5056
  log(
4121
- `[${job.jobId.slice(0, 8)}] Payment confirmed: ${formatAssetAmount(
4122
- jobAsset,
4123
- BigInt(netAmount)
4124
- )}`
5057
+ `[${job.jobId.slice(0, 8)}] Payment confirmed: ${formatAssetAmount(
5058
+ jobAsset,
5059
+ BigInt(netAmount)
5060
+ )}`
5061
+ );
5062
+ this.callbacks.onPaymentReceived?.(job.jobId, netAmount);
5063
+ }
5064
+ try {
5065
+ const inputFile = await this.resolveInputFile(job.attachment, job.customerId, signal);
5066
+ await this.transport.sendFeedback(job, { type: "processing" }).catch(() => {
5067
+ });
5068
+ const skill = this.skills.route(job.tags);
5069
+ if (!skill) {
5070
+ throw new Error("No skill matched for tags: " + job.tags.join(", "));
5071
+ }
5072
+ log(`[${job.jobId.slice(0, 8)}] Executing skill: ${skill.name}`);
5073
+ const budgetMs = this.resolveExecutionBudgetMs(skill);
5074
+ const jobSession = this.resolveJobSession(job, skill);
5075
+ const runExecution = async (history) => {
5076
+ let budgetExceeded = false;
5077
+ const execAbort = new AbortController();
5078
+ const onOuterAbort = () => execAbort.abort();
5079
+ if (signal) {
5080
+ if (signal.aborted) {
5081
+ execAbort.abort();
5082
+ } else {
5083
+ signal.addEventListener("abort", onOuterAbort);
5084
+ }
5085
+ }
5086
+ let budgetTimer;
5087
+ try {
5088
+ const execPromise = skill.execute(
5089
+ {
5090
+ data: inputFile?.inlineText ?? job.input,
5091
+ inputType: job.inputType,
5092
+ tags: job.tags,
5093
+ jobId: job.jobId,
5094
+ filePath: inputFile?.filePath,
5095
+ history,
5096
+ ...jobSession !== null ? { sessionId: jobSession.sessionId } : {}
5097
+ },
5098
+ { ...this.skillCtx, signal: execAbort.signal }
5099
+ );
5100
+ execPromise.catch(() => {
5101
+ });
5102
+ if (budgetMs > 0) {
5103
+ return await Promise.race([
5104
+ execPromise,
5105
+ new Promise((_resolve, reject) => {
5106
+ budgetTimer = setTimeout(() => {
5107
+ budgetExceeded = true;
5108
+ execAbort.abort();
5109
+ reject(new ExecutionBudgetExceededError(budgetMs));
5110
+ }, budgetMs);
5111
+ })
5112
+ ]);
5113
+ }
5114
+ return await execPromise;
5115
+ } catch (err) {
5116
+ if (budgetExceeded) {
5117
+ log(`[${job.jobId.slice(0, 8)}] Execution exceeded budget (${budgetMs / 1e3}s)`);
5118
+ throw new ExecutionBudgetExceededError(budgetMs);
5119
+ }
5120
+ const flippedToUnhealthy = this.markHealthFromExecuteError(skill, err, log, job.jobId);
5121
+ if (flippedToUnhealthy) {
5122
+ throw new AgentUnavailableError();
5123
+ }
5124
+ throw err;
5125
+ } finally {
5126
+ if (budgetTimer) {
5127
+ clearTimeout(budgetTimer);
5128
+ }
5129
+ if (signal) {
5130
+ signal.removeEventListener("abort", onOuterAbort);
5131
+ }
5132
+ if (inputFile) {
5133
+ await inputFile.cleanup().catch(() => {
5134
+ });
5135
+ }
5136
+ }
5137
+ };
5138
+ const output = jobSession === null ? await runExecution() : await this.runSessionExchange({
5139
+ session: jobSession,
5140
+ jobId: job.jobId,
5141
+ userRecord: buildUserRecord(
5142
+ inputFile?.inlineText ?? job.input,
5143
+ inputFile?.filePath !== void 0 ? job.attachment?.name ?? "attachment" : void 0
5144
+ ),
5145
+ signal,
5146
+ log,
5147
+ execute: runExecution
5148
+ });
5149
+ const { attachments, deliveredContent } = await this.buildResultAttachment(
5150
+ job.jobId,
5151
+ output,
5152
+ job.customerId,
5153
+ readAcceptedTransports(job.rawEvent.tags)
5154
+ );
5155
+ if (delegatedPull !== void 0) {
5156
+ if (deliveredContent === "" && attachments.length === 0) {
5157
+ this.ledger.markFailed(job.jobId);
5158
+ await this.transport.sendFeedback(job, {
5159
+ type: "error",
5160
+ message: `${DELEGATED_REJECTED_PREFIX}: the skill produced empty output - you were not charged. Please re-submit.`
5161
+ }).catch(() => {
5162
+ });
5163
+ return;
5164
+ }
5165
+ this.ledger.recordDeliveredContent(job.jobId, deliveredContent);
5166
+ const outcome = await this.executeDelegatedPull(job, delegatedPull, log);
5167
+ if (outcome === "dead") {
5168
+ this.ledger.markFailed(job.jobId);
5169
+ await this.transport.sendFeedback(job, {
5170
+ type: "error",
5171
+ message: `${DELEGATED_REJECTED_PREFIX}: the delegated transfer did not land - you were not charged. Please re-submit.`
5172
+ }).catch(() => {
5173
+ });
5174
+ return;
5175
+ }
5176
+ if (outcome === "unresolved") {
5177
+ log(
5178
+ `[${job.jobId.slice(0, 8)}] Delegated pull unresolved before terminal; recovery will reconcile.`
5179
+ );
5180
+ return;
5181
+ }
5182
+ }
5183
+ this.ledger.markExecuted(job.jobId, deliveredContent);
5184
+ log(`[${job.jobId.slice(0, 8)}] Skill completed, delivering result`);
5185
+ const eventId = await this.transport.deliverResult(
5186
+ job,
5187
+ deliveredContent,
5188
+ netAmount,
5189
+ attachments.length > 0 ? attachments : void 0,
5190
+ delegatedPull?.pullSignature
5191
+ );
5192
+ this.ledger.markDelivered(job.jobId);
5193
+ log(`[${job.jobId.slice(0, 8)}] Delivered: ${eventId.slice(0, 16)}...`);
5194
+ this.callbacks.onJobCompleted?.(job.jobId, output.data);
5195
+ } finally {
5196
+ if (delegatedPull !== void 0) {
5197
+ this.releaseDelegationReservation(delegatedPull.request.owner, delegatedPull.priceSubunits);
5198
+ }
5199
+ }
5200
+ }
5201
+ reserveDelegation(owner, amount) {
5202
+ this.delegationInFlight.set(owner, (this.delegationInFlight.get(owner) ?? 0n) + amount);
5203
+ }
5204
+ /** Saturates at zero; drops the key when the owner has nothing in flight. */
5205
+ releaseDelegationReservation(owner, amount) {
5206
+ const current = this.delegationInFlight.get(owner) ?? 0n;
5207
+ const next = current > amount ? current - amount : 0n;
5208
+ if (next === 0n) {
5209
+ this.delegationInFlight.delete(owner);
5210
+ } else {
5211
+ this.delegationInFlight.set(owner, next);
5212
+ }
5213
+ }
5214
+ /**
5215
+ * Delegated pre-check: fail-closed gates from format to funds, then the
5216
+ * atomic nonce burn, then the on-chain delegation check + per-owner
5217
+ * reservation. Returns the pull context, or `undefined` after rejecting
5218
+ * (entry marked failed + error feedback sent). Runs AFTER `recordPaid`, so
5219
+ * every reject must `markFailed` to avoid a dangling `paid` entry.
5220
+ */
5221
+ async delegatedPreCheck(job, skill, log) {
5222
+ const reject = async (operatorDetail, customerReason) => {
5223
+ log(`[${job.jobId.slice(0, 8)}] Delegated payment rejected: ${operatorDetail}`);
5224
+ this.ledger.markFailed(job.jobId);
5225
+ await this.transport.sendFeedback(job, {
5226
+ type: "error",
5227
+ message: `${DELEGATED_REJECTED_PREFIX}: ${customerReason}`
5228
+ }).catch(() => {
5229
+ });
5230
+ return void 0;
5231
+ };
5232
+ if (job.delegatedRejected !== void 0 || job.delegated === void 0) {
5233
+ return reject(
5234
+ `malformed delegation tags (${job.delegatedRejected ?? "missing struct"})`,
5235
+ "malformed delegation tags."
5236
+ );
5237
+ }
5238
+ const request = job.delegated;
5239
+ const delegateSigner = this.config.delegateSigner;
5240
+ if (delegateSigner === void 0 || this.nonceStore === void 0) {
5241
+ return reject(
5242
+ "agent has no delegate key configured",
5243
+ "this agent does not accept delegated payment."
5244
+ );
5245
+ }
5246
+ if (this.config.network !== "devnet") {
5247
+ return reject(
5248
+ `delegation is devnet-only (network: ${this.config.network})`,
5249
+ "this agent does not accept delegated payment."
5250
+ );
5251
+ }
5252
+ const network = this.config.network;
5253
+ if (!skill || skill.delegation === void 0 || skill.priceSubunits <= 0) {
5254
+ return reject(
5255
+ "skill does not accept delegated payment",
5256
+ "this skill does not accept delegated payment."
5257
+ );
5258
+ }
5259
+ const delegationAsset = resolveDelegationAsset(network);
5260
+ if (skill.asset.mint !== delegationAsset.mint) {
5261
+ return reject(
5262
+ `skill asset ${skill.asset.symbol} is not the delegation asset`,
5263
+ "this skill does not accept delegated payment."
5264
+ );
5265
+ }
5266
+ if (!this.config.solanaAddress) {
5267
+ return reject(
5268
+ "no solana payment address configured",
5269
+ "this agent does not accept delegated payment."
5270
+ );
5271
+ }
5272
+ const nowSecs = Math.floor(Date.now() / 1e3);
5273
+ if (request.expiryUnix <= nowSecs) {
5274
+ return reject("proof expired", "the payment proof has expired - re-submit the job.");
5275
+ }
5276
+ if (request.expiryUnix > nowSecs + MAX_PROOF_TTL_SECS + PROOF_CLOCK_SKEW_SECS) {
5277
+ return reject(
5278
+ "proof expiry beyond the allowed horizon",
5279
+ "the payment proof expiry is too far in the future."
5280
+ );
5281
+ }
5282
+ const proofValid = await verifyDelegationAuthProof({
5283
+ agentDelegate: delegateSigner.address,
5284
+ nostrAuthor: job.customerId,
5285
+ owner: request.owner,
5286
+ expiryUnix: request.expiryUnix,
5287
+ nonce: request.nonce,
5288
+ proof: request.proof
5289
+ });
5290
+ if (!proofValid) {
5291
+ return reject("proof verification failed", "invalid payment proof.");
5292
+ }
5293
+ const nonceKey = `${request.owner}:${request.nonce}`;
5294
+ if (this.nonceStore.has(nonceKey)) {
5295
+ return reject("nonce already used", "this payment proof was already used.");
5296
+ }
5297
+ this.nonceStore.markUsed(nonceKey, request.expiryUnix + PROOF_CLOCK_SKEW_SECS);
5298
+ this.ledger.markDelegated(job.jobId);
5299
+ let ownerAta;
5300
+ try {
5301
+ ownerAta = await deriveOwnerDelegationAta(request.owner, network);
5302
+ } catch (error) {
5303
+ return reject(
5304
+ `owner ATA derivation failed: ${error instanceof Error ? error.message : String(error)}`,
5305
+ "invalid delegation owner address."
5306
+ );
5307
+ }
5308
+ const rpc = createSolanaRpc(getRpcUrl(this.config.network));
5309
+ let status;
5310
+ try {
5311
+ status = await getDelegation(rpc, ownerAta);
5312
+ } catch (error) {
5313
+ return reject(
5314
+ `getDelegation failed: ${error instanceof Error ? error.message : String(error)}`,
5315
+ "could not verify the delegation on-chain - try again later."
5316
+ );
5317
+ }
5318
+ if (status === null || status.delegate !== delegateSigner.address) {
5319
+ return reject(
5320
+ `no active delegation for this delegate (current: ${status?.delegate ?? "none"})`,
5321
+ "no active delegation for this agent - approve it first."
5322
+ );
5323
+ }
5324
+ const priceSubunits = BigInt(skill.priceSubunits);
5325
+ const otherInFlight = this.delegationInFlight.get(request.owner) ?? 0n;
5326
+ if (status.remainingCap - otherInFlight < priceSubunits) {
5327
+ return reject(
5328
+ `remaining cap ${status.remainingCap} minus in-flight ${otherInFlight} below price ${priceSubunits}`,
5329
+ "the remaining delegated allowance is below this skill price."
5330
+ );
5331
+ }
5332
+ if (status.balance < priceSubunits) {
5333
+ return reject(
5334
+ `owner balance ${status.balance} below price ${priceSubunits}`,
5335
+ "the delegation account balance is below this skill price."
5336
+ );
5337
+ }
5338
+ this.reserveDelegation(request.owner, priceSubunits);
5339
+ log(
5340
+ `[${job.jobId.slice(0, 8)}] Delegated pre-check ok: ${formatAssetAmount(
5341
+ delegationAsset,
5342
+ priceSubunits
5343
+ )} from ${request.owner.slice(0, 8)}... (pull after work)`
5344
+ );
5345
+ return { request, delegateSigner, ownerAta, priceSubunits, rpc, network };
5346
+ }
5347
+ /**
5348
+ * The two-phase pull - the ONLY money move of a delegated job. Phase A signs
5349
+ * and PERSISTS `{signature, lastValidBlockHeight}` (+ the amount) before any
5350
+ * bytes hit the network; phase B sends and polls to a provable terminal.
5351
+ * Never throws past phase A: the outcome routes the caller.
5352
+ */
5353
+ async executeDelegatedPull(job, ctx, log) {
5354
+ if (!this.config.solanaAddress) {
5355
+ throw new Error("Solana address not configured");
5356
+ }
5357
+ const destinationAta = await deriveOwnerDelegationAta(this.config.solanaAddress, ctx.network);
5358
+ const instructions = await buildDelegatedTransfer({
5359
+ delegate: ctx.delegateSigner,
5360
+ source: ctx.ownerAta,
5361
+ destination: destinationAta,
5362
+ amount: ctx.priceSubunits,
5363
+ network: ctx.network,
5364
+ // Idempotent create (delegate-paid rent) so a fresh provider wallet's
5365
+ // first delegated job does not dead-letter on a missing ATA.
5366
+ ensureDestination: { owner: this.config.solanaAddress }
5367
+ });
5368
+ const pull = await buildSignedPull(ctx.rpc, ctx.delegateSigner, instructions);
5369
+ this.ledger.recordPullSignature(
5370
+ job.jobId,
5371
+ pull.signature,
5372
+ Number(pull.lastValidBlockHeight),
5373
+ Number(ctx.priceSubunits)
5374
+ );
5375
+ ctx.pullSignature = pull.signature;
5376
+ const outcome = await sendConfirmToTerminal(ctx.rpc, pull, {});
5377
+ log(`[${job.jobId.slice(0, 8)}] Delegated pull ${outcome}: ${pull.signature.slice(0, 16)}...`);
5378
+ return outcome;
5379
+ }
5380
+ /**
5381
+ * Restart reconciliation of the durable used-nonce set: re-burn every nonce
5382
+ * found in persisted raw events (any status - a nonce is burned the moment
5383
+ * its event entered the ledger's delegated path; fail-closed re-covers even
5384
+ * pre-mark rejects). Runs BEFORE `transport.start`.
5385
+ */
5386
+ reconcileUsedNonces() {
5387
+ if (this.nonceStore === void 0) {
5388
+ return;
5389
+ }
5390
+ const nowSecs = Math.floor(Date.now() / 1e3);
5391
+ let changed = false;
5392
+ for (const entry of this.ledger.allEntries()) {
5393
+ if (entry.raw_event_json === void 0) {
5394
+ continue;
5395
+ }
5396
+ try {
5397
+ const parsed = parseDelegatedPayment(JSON.parse(entry.raw_event_json));
5398
+ if (parsed === null) {
5399
+ continue;
5400
+ }
5401
+ const retainUntil = parsed.expiryUnix + PROOF_CLOCK_SKEW_SECS;
5402
+ if (retainUntil <= nowSecs) {
5403
+ continue;
5404
+ }
5405
+ const key = `${parsed.owner}:${parsed.nonce}`;
5406
+ if (!this.nonceStore.has(key)) {
5407
+ this.nonceStore.markUsed(key, retainUntil, { persist: false });
5408
+ changed = true;
5409
+ }
5410
+ } catch {
5411
+ }
5412
+ }
5413
+ if (changed) {
5414
+ try {
5415
+ this.nonceStore.flush();
5416
+ } catch {
5417
+ }
5418
+ }
5419
+ }
5420
+ /**
5421
+ * Delegated discriminator for recovery routing. The ledger flag is primary;
5422
+ * the persisted raw event's top-level `payment` tag is the fallback for an
5423
+ * entry whose flag write was lost.
5424
+ */
5425
+ isDelegatedEntry(entry) {
5426
+ if (entry.delegated === true) {
5427
+ return true;
5428
+ }
5429
+ if (entry.raw_event_json === void 0) {
5430
+ return false;
5431
+ }
5432
+ try {
5433
+ const raw = JSON.parse(entry.raw_event_json);
5434
+ return Array.isArray(raw.tags) && raw.tags.some((tag) => Array.isArray(tag) && tag[0] === "payment" && tag[1] === "delegated");
5435
+ } catch {
5436
+ return false;
5437
+ }
5438
+ }
5439
+ /**
5440
+ * Reconcile a delegated `paid` entry to its terminal - the FIRST action in
5441
+ * the recovery `paid` branch, BEFORE skill.route/health/preflight gates: a
5442
+ * renamed skill or an unhealthy LLM must never strand an already-charged
5443
+ * pull, and redelivering a persisted result needs none of them. Operates
5444
+ * purely on persisted state - never re-executes, never re-signs.
5445
+ */
5446
+ async reconcileDelegatedEntry(entry, fakeJob, log) {
5447
+ if (entry.pull_signature === void 0) {
5448
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: delegated entry has no pull, marking failed`);
5449
+ this.ledger.markFailed(entry.job_id);
5450
+ await this.transport.sendFeedback(fakeJob, {
5451
+ type: "error",
5452
+ message: `${DELEGATED_REJECTED_PREFIX}: the job did not complete - you were not charged. Please re-submit.`
5453
+ }).catch(() => {
5454
+ });
5455
+ return;
5456
+ }
5457
+ const rpc = createSolanaRpc(getRpcUrl(this.config.network));
5458
+ const pullSignature = signature(entry.pull_signature);
5459
+ let outcome;
5460
+ if (entry.pull_last_valid_block_height !== void 0) {
5461
+ outcome = await confirmPullToTerminal(
5462
+ rpc,
5463
+ pullSignature,
5464
+ BigInt(entry.pull_last_valid_block_height),
5465
+ { recheckAttempts: RECONCILE_RECHECK_ATTEMPTS }
5466
+ );
5467
+ } else {
5468
+ outcome = await isDefinitelyUnpaid(rpc, pullSignature, {
5469
+ recheckAttempts: RECONCILE_RECHECK_ATTEMPTS
5470
+ }) ? "dead" : "assume-landed";
5471
+ }
5472
+ if (outcome === "unresolved") {
5473
+ log(
5474
+ `[${entry.job_id.slice(0, 8)}] Recovery: delegated pull still unresolved; retrying next tick`
5475
+ );
5476
+ return;
5477
+ }
5478
+ if (outcome === "dead") {
5479
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: delegated pull provably dead, marking failed`);
5480
+ this.ledger.markFailed(entry.job_id);
5481
+ await this.transport.sendFeedback(fakeJob, {
5482
+ type: "error",
5483
+ message: `${DELEGATED_REJECTED_PREFIX}: the delegated transfer did not land - you were not charged. Please re-submit.`
5484
+ }).catch(() => {
5485
+ });
5486
+ return;
5487
+ }
5488
+ let attachments;
5489
+ try {
5490
+ attachments = await this.reShareResultAttachments(entry);
5491
+ } catch {
5492
+ log(
5493
+ `[${entry.job_id.slice(0, 8)}] Recovery: delegated result blob unavailable, marking failed`
4125
5494
  );
4126
- this.callbacks.onPaymentReceived?.(job.jobId, netAmount);
5495
+ this.ledger.markFailed(entry.job_id);
5496
+ await this.transport.sendFeedback(fakeJob, {
5497
+ type: "error",
5498
+ message: `Job permanently failed: the result could not be recovered after an agent restart. The delegated transfer landed (tx ${pullSignature}) - contact the provider to resolve.`
5499
+ }).catch(() => {
5500
+ });
5501
+ return;
4127
5502
  }
4128
- const inputFile = await this.resolveInputFile(job.attachment, job.customerId, signal);
4129
- await this.transport.sendFeedback(job, { type: "processing" }).catch(() => {
4130
- });
4131
- const skill = this.skills.route(job.tags);
4132
- if (!skill) {
4133
- throw new Error("No skill matched for tags: " + job.tags.join(", "));
4134
- }
4135
- log(`[${job.jobId.slice(0, 8)}] Executing skill: ${skill.name}`);
4136
- const budgetMs = this.resolveExecutionBudgetMs(skill);
4137
- const jobSession = this.resolveJobSession(job, skill);
4138
- const runExecution = async (history) => {
4139
- let budgetExceeded = false;
4140
- const execAbort = new AbortController();
4141
- const onOuterAbort = () => execAbort.abort();
4142
- if (signal) {
4143
- if (signal.aborted) {
4144
- execAbort.abort();
4145
- } else {
4146
- signal.addEventListener("abort", onOuterAbort);
4147
- }
4148
- }
4149
- let budgetTimer;
4150
- try {
4151
- const execPromise = skill.execute(
4152
- {
4153
- data: inputFile?.inlineText ?? job.input,
4154
- inputType: job.inputType,
4155
- tags: job.tags,
4156
- jobId: job.jobId,
4157
- filePath: inputFile?.filePath,
4158
- history,
4159
- ...jobSession !== null ? { sessionId: jobSession.sessionId } : {}
4160
- },
4161
- { ...this.skillCtx, signal: execAbort.signal }
4162
- );
4163
- execPromise.catch(() => {
4164
- });
4165
- if (budgetMs > 0) {
4166
- return await Promise.race([
4167
- execPromise,
4168
- new Promise((_resolve, reject) => {
4169
- budgetTimer = setTimeout(() => {
4170
- budgetExceeded = true;
4171
- execAbort.abort();
4172
- reject(new ExecutionBudgetExceededError(budgetMs));
4173
- }, budgetMs);
4174
- })
4175
- ]);
4176
- }
4177
- return await execPromise;
4178
- } catch (err) {
4179
- if (budgetExceeded) {
4180
- log(`[${job.jobId.slice(0, 8)}] Execution exceeded budget (${budgetMs / 1e3}s)`);
4181
- throw new ExecutionBudgetExceededError(budgetMs);
4182
- }
4183
- const flippedToUnhealthy = this.markHealthFromExecuteError(skill, err, log, job.jobId);
4184
- if (flippedToUnhealthy) {
4185
- throw new AgentUnavailableError();
4186
- }
4187
- throw err;
4188
- } finally {
4189
- if (budgetTimer) {
4190
- clearTimeout(budgetTimer);
4191
- }
4192
- if (signal) {
4193
- signal.removeEventListener("abort", onOuterAbort);
4194
- }
4195
- if (inputFile) {
4196
- await inputFile.cleanup().catch(() => {
4197
- });
4198
- }
4199
- }
4200
- };
4201
- const output = jobSession === null ? await runExecution() : await this.runSessionExchange({
4202
- session: jobSession,
4203
- jobId: job.jobId,
4204
- userRecord: buildUserRecord(
4205
- inputFile?.inlineText ?? job.input,
4206
- inputFile?.filePath !== void 0 ? job.attachment?.name ?? "attachment" : void 0
4207
- ),
4208
- signal,
4209
- log,
4210
- execute: runExecution
4211
- });
4212
- const { attachments, deliveredContent } = await this.buildResultAttachment(
4213
- job.jobId,
4214
- output,
4215
- job.customerId,
4216
- readAcceptedTransports(job.rawEvent.tags)
5503
+ this.ledger.markExecuted(entry.job_id, entry.delivered_content ?? "");
5504
+ await this.transport.deliverResult(
5505
+ fakeJob,
5506
+ entry.delivered_content ?? "",
5507
+ entry.net_amount,
5508
+ attachments.length > 0 ? attachments : void 0,
5509
+ entry.pull_signature
4217
5510
  );
4218
- this.ledger.markExecuted(job.jobId, deliveredContent);
4219
- log(`[${job.jobId.slice(0, 8)}] Skill completed, delivering result`);
4220
- const eventId = await this.transport.deliverResult(
4221
- job,
4222
- deliveredContent,
4223
- netAmount,
4224
- attachments.length > 0 ? attachments : void 0
4225
- );
4226
- this.ledger.markDelivered(job.jobId);
4227
- log(`[${job.jobId.slice(0, 8)}] Delivered: ${eventId.slice(0, 16)}...`);
4228
- this.callbacks.onJobCompleted?.(job.jobId, output.data);
5511
+ this.ledger.markDelivered(entry.job_id);
5512
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: delegated pull ${outcome}, result delivered`);
4229
5513
  }
4230
5514
  /**
4231
5515
  * Seed a result blob through iroh, timing it and converting any failure (incl. the
@@ -4792,7 +6076,8 @@ var AgentRuntime = class {
4792
6076
  const ageMs = (Math.floor(Date.now() / 1e3) - entry.created_at) * 1e3;
4793
6077
  const expired = ageMs > MAX_PAID_AGE_MS;
4794
6078
  const exhaustedRetries = entry.status === "paid" && entry.retry_count >= this.config.recoveryMaxRetries;
4795
- if (expired || exhaustedRetries) {
6079
+ const hasPullSignature = entry.pull_signature !== void 0;
6080
+ if ((expired || exhaustedRetries) && !hasPullSignature) {
4796
6081
  this.ledger.markFailed(entry.job_id);
4797
6082
  const reason = expired ? "Job permanently failed: agent did not recover within 24 hours" : "Job permanently failed after maximum retries";
4798
6083
  if (entry.raw_event_json) {
@@ -4861,17 +6146,27 @@ var AgentRuntime = class {
4861
6146
  } catch {
4862
6147
  log(`[${entry.job_id.slice(0, 8)}] Recovery: result blob unavailable, marking failed`);
4863
6148
  this.ledger.markFailed(entry.job_id);
6149
+ await this.transport.sendFeedback(fakeJob, {
6150
+ type: "error",
6151
+ message: "Job permanently failed: the result data was lost after an agent restart - contact the provider to resolve."
6152
+ }).catch(() => {
6153
+ });
4864
6154
  return;
4865
6155
  }
4866
6156
  await this.transport.deliverResult(
4867
6157
  fakeJob,
4868
6158
  entry.result,
4869
6159
  entry.net_amount,
4870
- attachments.length > 0 ? attachments : void 0
6160
+ attachments.length > 0 ? attachments : void 0,
6161
+ entry.pull_signature
4871
6162
  );
4872
6163
  this.ledger.markDelivered(entry.job_id);
4873
6164
  log(`[${entry.job_id.slice(0, 8)}] Recovery: re-delivered`);
4874
6165
  } else if (entry.status === "paid") {
6166
+ if (this.isDelegatedEntry(entry)) {
6167
+ await this.reconcileDelegatedEntry(entry, fakeJob, log);
6168
+ return;
6169
+ }
4875
6170
  const skill = this.skills.route(entry.tags);
4876
6171
  if (!skill) {
4877
6172
  log(`[${entry.job_id.slice(0, 8)}] Recovery: no skill for tags, marking failed`);
@@ -5504,6 +6799,9 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
5504
6799
  if (parsed.executionTimeoutSecs !== void 0) {
5505
6800
  skill.executionTimeoutSecs = parsed.executionTimeoutSecs;
5506
6801
  }
6802
+ if (parsed.delegation) {
6803
+ skill.delegation = parsed.delegation;
6804
+ }
5507
6805
  return skill;
5508
6806
  }
5509
6807
  function loadSkillsFromDir(skillsDir, options = {}) {
@@ -5600,6 +6898,13 @@ var NostrTransport = class {
5600
6898
  } catch {
5601
6899
  return;
5602
6900
  }
6901
+ let delegated;
6902
+ let delegatedRejected;
6903
+ try {
6904
+ delegated = parseDelegatedPayment(event) ?? void 0;
6905
+ } catch (error) {
6906
+ delegatedRejected = error instanceof Error ? error.message : String(error);
6907
+ }
5603
6908
  onJob({
5604
6909
  jobId: event.id,
5605
6910
  input,
@@ -5610,7 +6915,9 @@ var NostrTransport = class {
5610
6915
  encrypted,
5611
6916
  rawEvent: event,
5612
6917
  attachment,
5613
- session
6918
+ session,
6919
+ delegated,
6920
+ delegatedRejected
5614
6921
  });
5615
6922
  }
5616
6923
  );
@@ -5707,8 +7014,12 @@ var NostrTransport = class {
5707
7014
  );
5708
7015
  }
5709
7016
  }
5710
- /** Deliver result to customer. Retries with exponential backoff via SDK. */
5711
- async deliverResult(job, content, amount, attachments, retries = 3) {
7017
+ /**
7018
+ * Deliver result to customer. Retries with exponential backoff via SDK.
7019
+ * `paymentTx` attaches an on-chain settlement signature (the delegated pull)
7020
+ * to the result event for customer-side transparency.
7021
+ */
7022
+ async deliverResult(job, content, amount, attachments, paymentTx, retries = 3) {
5712
7023
  return this.client.marketplace.submitJobResultWithRetry(
5713
7024
  this.identity,
5714
7025
  job.rawEvent,
@@ -5716,7 +7027,8 @@ var NostrTransport = class {
5716
7027
  amount,
5717
7028
  retries,
5718
7029
  void 0,
5719
- attachments
7030
+ attachments,
7031
+ paymentTx !== void 0 ? { paymentTx } : void 0
5720
7032
  );
5721
7033
  }
5722
7034
  /** Returns true if an event was received within the given idle window. */
@@ -5852,216 +7164,6 @@ function startWatchdog(deps) {
5852
7164
  }
5853
7165
  };
5854
7166
  }
5855
-
5856
- // src/x402/constants.ts
5857
- var X402_SOLANA_DEVNET_CAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";
5858
- var X402_SOLANA_DEVNET_V1 = "solana-devnet";
5859
- var X402_GET_INPUT_MAX_ENCODED_BYTES = 2048;
5860
- var X402_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
5861
- var X402_MAX_CHALLENGE_BYTES = 256 * 1024;
5862
- var X402_PROBE_TTL_MS = 3e4;
5863
- var X402_MAX_PAID_ATTEMPTS = 2;
5864
- var X402_MAX_PAYMENT_SIGNATURES = 2 * X402_MAX_PAID_ATTEMPTS;
5865
- var X402_FREE_RETRY_DELAYS_MS = [1e3, 3e3];
5866
- var X402_REFUNDED_RETRIES = 1;
5867
- var X402_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
5868
- function requirementAmount(requirement) {
5869
- const v1Amount = requirement.maxAmountRequired;
5870
- const raw = requirement.amount ?? v1Amount;
5871
- if (typeof raw !== "string" || !/^[0-9]+$/.test(raw)) {
5872
- return null;
5873
- }
5874
- return BigInt(raw);
5875
- }
5876
- function isAcceptableRequirement(requirement, rule) {
5877
- if (requirement.scheme !== "exact") {
5878
- return false;
5879
- }
5880
- const network = requirement.network;
5881
- if (network !== X402_SOLANA_DEVNET_CAIP2 && network !== X402_SOLANA_DEVNET_V1) {
5882
- return false;
5883
- }
5884
- if (USDC_SOLANA_DEVNET.mint === void 0 || requirement.asset !== USDC_SOLANA_DEVNET.mint) {
5885
- return false;
5886
- }
5887
- const amount = requirementAmount(requirement);
5888
- if (amount === null) {
5889
- return false;
5890
- }
5891
- return amount <= rule.maxUpstreamSubunits;
5892
- }
5893
- function selectAcceptableRequirement(accepts, rule) {
5894
- return accepts.find((requirement) => isAcceptableRequirement(requirement, rule));
5895
- }
5896
- function maxAcceptableQuote(accepts, rule) {
5897
- let worst = null;
5898
- for (const requirement of accepts) {
5899
- if (!isAcceptableRequirement(requirement, rule)) {
5900
- continue;
5901
- }
5902
- const amount = requirementAmount(requirement);
5903
- if (amount === null) {
5904
- continue;
5905
- }
5906
- if (worst === null || amount > worst) {
5907
- worst = amount;
5908
- }
5909
- }
5910
- return worst;
5911
- }
5912
- function buildRequirementsPolicy(rule) {
5913
- return function filterAcceptableRequirements(_x402Version, paymentRequirements) {
5914
- return paymentRequirements.filter((requirement) => isAcceptableRequirement(requirement, rule));
5915
- };
5916
- }
5917
- var PROBE_TIMEOUT_MS = 1e4;
5918
- var MAX_PROBE_BODY_BYTES = 256 * 1024;
5919
- var PROBE_CACHE_MAX_ENTRIES = 256;
5920
- async function readTextCapped(response, maxBytes) {
5921
- const reader = response.body?.getReader();
5922
- if (reader === void 0) {
5923
- const buffer = await response.arrayBuffer();
5924
- if (buffer.byteLength > maxBytes) {
5925
- throw new X402ProbeError(`402 body exceeds ${maxBytes} bytes`, 402);
5926
- }
5927
- return new TextDecoder().decode(buffer);
5928
- }
5929
- const chunks = [];
5930
- let total = 0;
5931
- while (true) {
5932
- const { done, value } = await reader.read();
5933
- if (done) {
5934
- break;
5935
- }
5936
- total += value.byteLength;
5937
- if (total > maxBytes) {
5938
- await reader.cancel().catch(() => {
5939
- });
5940
- throw new X402ProbeError(`402 body exceeds ${maxBytes} bytes`, 402);
5941
- }
5942
- chunks.push(value);
5943
- }
5944
- const combined = new Uint8Array(total);
5945
- let offset = 0;
5946
- for (const chunk of chunks) {
5947
- combined.set(chunk, offset);
5948
- offset += chunk.byteLength;
5949
- }
5950
- return new TextDecoder().decode(combined);
5951
- }
5952
- var X402ProbeError = class extends Error {
5953
- status;
5954
- constructor(message, status) {
5955
- super(message);
5956
- this.name = "X402ProbeError";
5957
- this.status = status;
5958
- }
5959
- };
5960
- function normalizeV1Requirement(raw) {
5961
- return {
5962
- scheme: typeof raw.scheme === "string" ? raw.scheme : "",
5963
- network: typeof raw.network === "string" ? raw.network : "unknown:unknown",
5964
- asset: typeof raw.asset === "string" ? raw.asset : "",
5965
- payTo: typeof raw.payTo === "string" ? raw.payTo : "",
5966
- amount: typeof raw.maxAmountRequired === "string" ? raw.maxAmountRequired : "",
5967
- maxTimeoutSeconds: typeof raw.maxTimeoutSeconds === "number" ? raw.maxTimeoutSeconds : 60,
5968
- extra: raw.extra ?? {}
5969
- };
5970
- }
5971
- function parseV1Body(bodyText) {
5972
- let parsed;
5973
- try {
5974
- parsed = JSON.parse(bodyText);
5975
- } catch {
5976
- return null;
5977
- }
5978
- if (typeof parsed !== "object" || parsed === null) {
5979
- return null;
5980
- }
5981
- const body = parsed;
5982
- if (body.x402Version !== 1 || !Array.isArray(body.accepts) || body.accepts.length === 0) {
5983
- return null;
5984
- }
5985
- const rawRequirements = body.accepts.filter(
5986
- (item) => typeof item === "object" && item !== null
5987
- );
5988
- if (rawRequirements.length === 0) {
5989
- return null;
5990
- }
5991
- const accepts = rawRequirements.map(normalizeV1Requirement);
5992
- const first = rawRequirements[0];
5993
- return {
5994
- x402Version: 1,
5995
- accepts,
5996
- resource: {
5997
- url: typeof first.resource === "string" ? first.resource : void 0,
5998
- description: typeof first.description === "string" ? first.description : void 0,
5999
- mimeType: typeof first.mimeType === "string" ? first.mimeType : void 0
6000
- }
6001
- };
6002
- }
6003
- async function probePaymentRequired(url, method, signal) {
6004
- const probeSignal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
6005
- const response = await fetch(url, { method, signal: probeSignal, redirect: "error" });
6006
- if (response.status !== 402) {
6007
- await response.body?.cancel().catch(() => {
6008
- });
6009
- throw new X402ProbeError(
6010
- `expected HTTP 402 from ${url}, got ${response.status} - not an x402-paid endpoint (or it validates the request before the paywall; try the other method)`,
6011
- response.status
6012
- );
6013
- }
6014
- const headerValue = response.headers.get("PAYMENT-REQUIRED");
6015
- if (headerValue !== null) {
6016
- await response.body?.cancel().catch(() => {
6017
- });
6018
- try {
6019
- const paymentRequired = decodePaymentRequiredHeader(headerValue);
6020
- return {
6021
- x402Version: paymentRequired.x402Version,
6022
- accepts: paymentRequired.accepts,
6023
- resource: paymentRequired.resource
6024
- };
6025
- } catch (error) {
6026
- const message = error instanceof Error ? error.message : String(error);
6027
- throw new X402ProbeError(`could not decode PAYMENT-REQUIRED header: ${message}`, 402);
6028
- }
6029
- }
6030
- const bodyText = await readTextCapped(response, MAX_PROBE_BODY_BYTES).catch((error) => {
6031
- if (error instanceof X402ProbeError) {
6032
- throw error;
6033
- }
6034
- return "";
6035
- });
6036
- const v1 = parseV1Body(bodyText);
6037
- if (v1 !== null) {
6038
- return v1;
6039
- }
6040
- throw new X402ProbeError(
6041
- "got HTTP 402 but neither a v2 PAYMENT-REQUIRED header nor a v1 JSON body was parseable",
6042
- 402
6043
- );
6044
- }
6045
- var PROBE_CACHE = /* @__PURE__ */ new Map();
6046
- async function probePaymentRequiredCached(url, method, ttlMs) {
6047
- const key = `${method} ${url}`;
6048
- const cached = PROBE_CACHE.get(key);
6049
- const now = Date.now();
6050
- if (cached !== void 0 && now - cached.at < ttlMs) {
6051
- return cached.result;
6052
- }
6053
- const result = await probePaymentRequired(url, method);
6054
- PROBE_CACHE.delete(key);
6055
- PROBE_CACHE.set(key, { at: now, result });
6056
- while (PROBE_CACHE.size > PROBE_CACHE_MAX_ENTRIES) {
6057
- const oldest = PROBE_CACHE.keys().next().value;
6058
- if (oldest === void 0) {
6059
- break;
6060
- }
6061
- PROBE_CACHE.delete(oldest);
6062
- }
6063
- return result;
6064
- }
6065
7167
  var X402_JOBS_FILE = ".x402-jobs.json";
6066
7168
  var X402_RESULTS_DIR = ".x402-results";
6067
7169
  function sanitizeJobId(jobId) {
@@ -6691,18 +7793,7 @@ async function cmdStart(nameArg, options = {}) {
6691
7793
  `);
6692
7794
  }
6693
7795
  }
6694
- const scriptEnv = { ...process.env };
6695
- delete scriptEnv.ELISYM_PASSPHRASE;
6696
- if (scriptEnv.SOLANA_RPC_URL !== void 0 && !isPublicSolanaRpcUrl(scriptEnv.SOLANA_RPC_URL)) {
6697
- delete scriptEnv.SOLANA_RPC_URL;
6698
- }
6699
- const llmKeys = loaded.secrets.llm_api_keys ?? {};
6700
- for (const descriptor of listLlmProviders()) {
6701
- const secretValue = llmKeys[descriptor.id];
6702
- if (typeof secretValue === "string" && secretValue.length > 0) {
6703
- scriptEnv[descriptor.envVar] = secretValue;
6704
- }
6705
- }
7796
+ const scriptEnv = buildScriptEnv(loaded.secrets);
6706
7797
  const paths = agentPaths(loaded.dir);
6707
7798
  const skillsDir = paths.skills;
6708
7799
  const allSkills = loadSkillsFromDir(skillsDir, { scriptEnv });
@@ -7122,6 +8213,46 @@ async function cmdStart(nameArg, options = {}) {
7122
8213
  }
7123
8214
  }
7124
8215
  const kinds = [jobRequestKind(DEFAULT_KIND_OFFSET)];
8216
+ const declaresDelegation = allSkills.some((skill) => skill.delegation !== void 0);
8217
+ let delegatePubkey;
8218
+ let delegateSigner;
8219
+ if (declaresDelegation && walletNetwork !== "devnet") {
8220
+ console.warn(
8221
+ ` ! A skill declares delegation but it is devnet-only today (network: ${walletNetwork}) - omitted from its card(s).`
8222
+ );
8223
+ } else if (declaresDelegation) {
8224
+ const delegateSecret = loaded.secrets.solana_delegate_secret_key;
8225
+ if (delegateSecret && delegateSecret.length > 0) {
8226
+ try {
8227
+ delegateSigner = await signerFromSecretKeyBase58(delegateSecret);
8228
+ delegatePubkey = delegateSigner.address;
8229
+ console.log(` Delegate (spl-approve) ${delegatePubkey}`);
8230
+ try {
8231
+ const { value: delegateLamports } = await createSolanaRpc(getRpcUrl(walletNetwork)).getBalance(address(delegatePubkey)).send();
8232
+ if (delegateLamports === 0n) {
8233
+ console.warn(
8234
+ " ! Delegate key holds no SOL - delegated job pulls will fail on tx fees. Fund it: https://faucet.solana.com"
8235
+ );
8236
+ }
8237
+ } catch {
8238
+ }
8239
+ } catch (error) {
8240
+ delegateSigner = void 0;
8241
+ delegatePubkey = void 0;
8242
+ console.warn(
8243
+ ` ! Delegate key is present but unreadable - the delegation field is omitted from its card(s). ${error instanceof Error ? error.message : String(error)}`
8244
+ );
8245
+ console.warn(
8246
+ ` ! Run \`npx @elisym/cli delegate-key ${loaded.name} --rotate\` to replace it.`
8247
+ );
8248
+ }
8249
+ } else {
8250
+ console.warn(
8251
+ " ! A skill declares delegation but this agent has no delegate key - the delegation field is omitted from its card(s)."
8252
+ );
8253
+ console.warn(` ! Run \`npx @elisym/cli delegate-key ${loaded.name}\` to enable it.`);
8254
+ }
8255
+ }
7125
8256
  function buildCard(skill) {
7126
8257
  const isStatic = skill.mode === "static-file" || skill.mode === "static-script" || skill.noInput === true;
7127
8258
  return {
@@ -7138,6 +8269,10 @@ async function cmdStart(nameArg, options = {}) {
7138
8269
  // Conversation-context flag: clients gate chat affordances (session-carrying
7139
8270
  // sends) on it. Strict true only - the read side coerces anything else away.
7140
8271
  ...skill.context === true ? { context: true } : {},
8272
+ // Delegation descriptor: only when the skill opts in AND a delegate key
8273
+ // exists (delegatePubkey resolved). The pubkey is injected here, never
8274
+ // taken from the SKILL.md - the operator declares only the cap/mechanism.
8275
+ ...skill.delegation && delegatePubkey ? { delegation: { ...skill.delegation, delegate_pubkey: delegatePubkey } } : {},
7141
8276
  payment: solanaAddress ? {
7142
8277
  chain: "solana",
7143
8278
  network: walletNetwork,
@@ -7247,8 +8382,15 @@ async function cmdStart(nameArg, options = {}) {
7247
8382
  solanaAddress,
7248
8383
  // Agent-level default execution budget; per-skill `max_execution_secs`
7249
8384
  // overrides it. Undefined => unlimited (operator-owned, no protocol default).
7250
- executionTimeoutSecs: loaded.yaml.execution_timeout_secs
8385
+ executionTimeoutSecs: loaded.yaml.execution_timeout_secs,
8386
+ // Delegated job payment: the runtime holds ONLY the delegate signer (never
8387
+ // the payment key) to execute post-work pulls from customer delegations.
8388
+ delegateSigner
7251
8389
  };
8390
+ const nonceStore = delegateSigner !== void 0 ? new UsedNonceStore(join(loaded.dir, ".delegation-nonces.json")) : void 0;
8391
+ if (nonceStore !== void 0) {
8392
+ await ensureGitignoreHasDelegationNoncesEntry(dirname(loaded.dir));
8393
+ }
7252
8394
  const rpcUrlForLog = stripRpcSecrets(process.env.SOLANA_RPC_URL ?? getRpcUrl());
7253
8395
  logger.debug(
7254
8396
  {
@@ -7321,11 +8463,27 @@ async function cmdStart(nameArg, options = {}) {
7321
8463
  irohTransport,
7322
8464
  identity2,
7323
8465
  blossomTransport,
7324
- sessionStore
8466
+ sessionStore,
8467
+ nonceStore
7325
8468
  );
7326
8469
  console.log(" * Running. Press Ctrl+C to stop.\n");
7327
8470
  await runtime.run();
7328
8471
  }
8472
+ function buildScriptEnv(secrets) {
8473
+ const scriptEnv = { ...process.env };
8474
+ delete scriptEnv.ELISYM_PASSPHRASE;
8475
+ if (scriptEnv.SOLANA_RPC_URL !== void 0 && !isPublicSolanaRpcUrl(scriptEnv.SOLANA_RPC_URL)) {
8476
+ delete scriptEnv.SOLANA_RPC_URL;
8477
+ }
8478
+ const llmKeys = secrets.llm_api_keys ?? {};
8479
+ for (const descriptor of listLlmProviders()) {
8480
+ const secretValue = llmKeys[descriptor.id];
8481
+ if (typeof secretValue === "string" && secretValue.length > 0) {
8482
+ scriptEnv[descriptor.envVar] = secretValue;
8483
+ }
8484
+ }
8485
+ return scriptEnv;
8486
+ }
7329
8487
  var PUBLIC_SOLANA_RPC_HOSTS = /* @__PURE__ */ new Set([
7330
8488
  "api.devnet.solana.com",
7331
8489
  "api.mainnet-beta.solana.com",
@@ -7502,468 +8660,43 @@ async function loadAgentWithPrompt(name, cwd) {
7502
8660
  async function cmdWallet(name) {
7503
8661
  const cwd = process.cwd();
7504
8662
  if (!name) {
7505
- const agents = await listAgents(cwd);
7506
- if (agents.length === 0) {
7507
- console.error("No agents found.");
7508
- process.exit(1);
7509
- }
7510
- const { default: inquirer } = await import('inquirer');
7511
- const { selected } = await inquirer.prompt([
7512
- {
7513
- type: "list",
7514
- name: "selected",
7515
- message: "Select agent:",
7516
- choices: agents.map((agent) => ({
7517
- name: `${agent.name} (${agent.source})`,
7518
- value: agent.name
7519
- }))
7520
- }
7521
- ]);
7522
- name = selected;
7523
- }
7524
- const passphrase = process.env.ELISYM_PASSPHRASE;
7525
- const loaded = await loadAgent(name, cwd, passphrase);
7526
- const solPayment = loaded.yaml.payments.find((entry) => entry.chain === "solana");
7527
- if (!solPayment?.address) {
7528
- console.error("Solana address not configured for this agent.");
7529
- process.exit(1);
7530
- }
7531
- const rpcUrl = getRpcUrl(solPayment.network);
7532
- const rpc = createSolanaRpc(rpcUrl);
7533
- const walletAddress = address(solPayment.address);
7534
- const { value: balance } = await rpc.getBalance(walletAddress).send();
7535
- const usdcBalance = await fetchUsdcBalance(rpc, walletAddress);
7536
- console.log(`
7537
- Agent: ${name}`);
7538
- console.log(` Network: ${solPayment.network}`);
7539
- console.log(` Address: ${solPayment.address}`);
7540
- console.log(` SOL balance: ${formatSol(Number(balance))} (${balance} lamports)`);
7541
- console.log(` USDC balance: ${formatAssetAmount(USDC_SOLANA_DEVNET, usdcBalance)}
7542
- `);
7543
- }
7544
- var BPS_DENOMINATOR = 1e4;
7545
- var DEFAULT_MARGIN_BPS = 1e3;
7546
- var SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
7547
- var CIRCLE_FAUCET_URL = "https://faucet.circle.com";
7548
- function fail(message) {
7549
- console.error(`
7550
- ! ${message}
7551
- `);
7552
- process.exit(1);
7553
- }
7554
- function sanitizeUpstreamText(raw, maxLength) {
7555
- let withoutControls = "";
7556
- for (const char of raw) {
7557
- const code = char.codePointAt(0) ?? 0;
7558
- withoutControls += code < 32 || code === 127 ? " " : char;
7559
- }
7560
- return withoutControls.replace(/—/g, "-").replace(/\s+/g, " ").trim().slice(0, maxLength).trim();
7561
- }
7562
- function slugFromUrl(url) {
7563
- const pathPart = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? "";
7564
- const host = url.hostname.replace(/^www\./, "").split(".")[0] ?? "x402";
7565
- return pathPart.length > 0 ? `${host}-${pathPart}` : host;
7566
- }
7567
- function parseMarginBps(raw) {
7568
- if (raw === void 0) {
7569
- return DEFAULT_MARGIN_BPS;
7570
- }
7571
- const value = Number(raw);
7572
- if (!Number.isInteger(value) || value < 0 || value > 1e5) {
7573
- fail(`--margin-bps must be a non-negative integer (bps, 100 = 1%); got "${raw}"`);
7574
- }
7575
- return value;
7576
- }
7577
- async function parseImportedSecret(input) {
7578
- const trimmed = input.trim();
7579
- if (existsSync(trimmed)) {
7580
- const content = await readFile(trimmed, "utf-8");
7581
- let parsed;
7582
- try {
7583
- parsed = JSON.parse(content);
7584
- } catch {
7585
- throw new Error(`${trimmed} is not a valid solana-keygen JSON file`);
7586
- }
7587
- if (!Array.isArray(parsed) || parsed.length !== 64 || parsed.some(
7588
- (byte) => typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255
7589
- )) {
7590
- throw new Error(`${trimmed} must contain a JSON array of 64 bytes (solana-keygen format)`);
7591
- }
7592
- return encodeSecretKeyBase58(new Uint8Array(parsed));
7593
- }
7594
- try {
7595
- await signerFromSecretKeyBase58(trimmed);
7596
- } catch {
7597
- throw new Error("not a readable file and not a valid base58-encoded 64-byte secret key");
7598
- }
7599
- return trimmed;
7600
- }
7601
- function computeBridgePriceSubunits(quote, marginBps, feeBps) {
7602
- if (feeBps >= BPS_DENOMINATOR) {
7603
- throw new Error(`feeBps ${feeBps} >= ${BPS_DENOMINATOR} - refusing zero/negative denominator`);
7604
- }
7605
- return BigInt(
7606
- new Decimal(quote.toString()).mul(BPS_DENOMINATOR + marginBps).div(BPS_DENOMINATOR - feeBps).toDecimalPlaces(0, Decimal.ROUND_CEIL).toString()
7607
- );
7608
- }
7609
- function buildSkillMd(input) {
7610
- const frontmatter = {
7611
- name: input.skillName,
7612
- description: input.description,
7613
- capabilities: input.capabilities,
7614
- price: input.priceDisplay,
7615
- token: "usdc",
7616
- mode: "x402",
7617
- x402_url: input.url,
7618
- x402_method: input.method,
7619
- ...input.queryParam !== void 0 ? { x402_query_param: input.queryParam } : {},
7620
- x402_max_upstream: Number(input.quote),
7621
- // GET input is dominated by the ~2KB percent-encoded query-string limit,
7622
- // so the byte cap is written for POST bridges only.
7623
- ...input.method === "POST" ? { x402_max_input_bytes: DEFAULT_X402_MAX_INPUT_BYTES } : {}
7624
- };
7625
- const bodyLines = [
7626
- `# ${input.skillName}`,
7627
- "",
7628
- "Operator notes for this x402 bridge skill (the executor ignores this body):",
7629
- "",
7630
- `- Upstream: ${input.url} (${input.method})`,
7631
- `- Upstream quote at generation time: ${formatAssetAmount(USDC_SOLANA_DEVNET, input.quote)} (= x402_max_upstream, the signing ceiling)`,
7632
- `- Price: ${input.priceDisplay} USDC (margin ${input.marginPercent}%, protocol fee ${input.feePercent}% at generation time)`,
7633
- "- To reprice after an upstream change, re-run:",
7634
- "",
7635
- "```bash",
7636
- `npx @elisym/cli x402 add ${input.url}`,
7637
- "```"
7638
- ];
7639
- return `---
7640
- ${YAML.stringify(frontmatter)}---
7641
-
7642
- ${bodyLines.join("\n")}
7643
- `;
7644
- }
7645
- function scanExistingSkills(skillsDir, candidateDTag, url) {
7646
- const scan = {};
7647
- let entries;
7648
- try {
7649
- entries = readdirSync(skillsDir);
7650
- } catch {
7651
- return scan;
7652
- }
7653
- for (const entry of entries) {
7654
- const skillMdPath = join(skillsDir, entry, "SKILL.md");
7655
- try {
7656
- if (!statSync(join(skillsDir, entry)).isDirectory()) {
7657
- continue;
7658
- }
7659
- const { frontmatter } = parseSkillMd(readFileSync(skillMdPath, "utf-8"));
7660
- if (typeof frontmatter.name !== "string") {
7661
- continue;
7662
- }
7663
- if (typeof frontmatter.x402_url === "string" && frontmatter.x402_url === url) {
7664
- scan.sameUrlDir = join(skillsDir, entry);
7665
- scan.sameUrlName = frontmatter.name;
7666
- continue;
7667
- }
7668
- let existingDTag;
7669
- try {
7670
- existingDTag = toDTag(frontmatter.name);
7671
- } catch {
7672
- continue;
7673
- }
7674
- if (existingDTag === candidateDTag) {
7675
- scan.dTagCollision = frontmatter.name;
7676
- }
7677
- } catch {
7678
- continue;
7679
- }
7680
- }
7681
- return scan;
7682
- }
7683
- function describeUnacceptableAccepts(probe) {
7684
- const networks = [...new Set(probe.accepts.map((requirement) => String(requirement.network)))];
7685
- if (networks.length > 0 && networks.every((network) => network.startsWith("eip155:"))) {
7686
- return `the service accepts only EVM networks (${networks.join(", ")}) - not supported yet`;
7687
- }
7688
- if (networks.some((network) => network === SOLANA_MAINNET_CAIP2 || network === "solana")) {
7689
- return "the service settles on Solana mainnet - elisym currently runs on devnet (mainnet is on the roadmap)";
7690
- }
7691
- return `no exact-scheme devnet-USDC requirement found (service accepts: ${networks.join(", ") || "nothing parseable"})`;
7692
- }
7693
- async function cmdX402Add(url, agentName, options) {
7694
- const cwd = process.cwd();
7695
- const { default: inquirer } = await import('inquirer');
7696
- let parsedUrl;
7697
- try {
7698
- parsedUrl = new URL(url);
7699
- } catch {
7700
- fail(`"${url}" is not a valid URL`);
7701
- }
7702
- const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsedUrl.hostname);
7703
- if (parsedUrl.protocol !== "https:" && !(parsedUrl.protocol === "http:" && isLoopback)) {
7704
- fail("the upstream must be an https:// URL (plain http is allowed for localhost only)");
7705
- }
7706
- const methodRaw = (options.method ?? "POST").toUpperCase();
7707
- if (methodRaw !== "GET" && methodRaw !== "POST") {
7708
- fail(`--method must be GET or POST; got "${options.method}"`);
7709
- }
7710
- const method = methodRaw;
7711
- const marginBps = parseMarginBps(options.marginBps);
7712
- if (!agentName) {
7713
- const agents = await listAgents(cwd);
7714
- if (agents.length === 0) {
7715
- fail("no agents found - create one first: npx @elisym/cli init");
7716
- }
7717
- if (options.yes) {
7718
- fail("--yes needs an explicit agent argument: elisym x402 add <url> <agent> --yes");
8663
+ const agents = await listAgents(cwd);
8664
+ if (agents.length === 0) {
8665
+ console.error("No agents found.");
8666
+ process.exit(1);
7719
8667
  }
8668
+ const { default: inquirer } = await import('inquirer');
7720
8669
  const { selected } = await inquirer.prompt([
7721
8670
  {
7722
8671
  type: "list",
7723
8672
  name: "selected",
7724
- message: "Add the bridge skill to which agent?",
8673
+ message: "Select agent:",
7725
8674
  choices: agents.map((agent) => ({
7726
8675
  name: `${agent.name} (${agent.source})`,
7727
8676
  value: agent.name
7728
8677
  }))
7729
8678
  }
7730
8679
  ]);
7731
- agentName = selected;
8680
+ name = selected;
7732
8681
  }
7733
8682
  const passphrase = process.env.ELISYM_PASSPHRASE;
7734
- const loaded = await loadAgent(agentName, cwd, passphrase);
7735
- console.log(`
7736
- Probing ${url} (${method}, unpaid)...`);
7737
- let probe;
7738
- try {
7739
- probe = await probePaymentRequired(url, method);
7740
- } catch (error) {
7741
- if (error instanceof X402ProbeError) {
7742
- fail(error.message);
7743
- }
7744
- const message = error instanceof Error ? error.message : String(error);
7745
- fail(`upstream unreachable: ${message}`);
7746
- }
7747
- const anyCeiling = { maxUpstreamSubunits: BigInt(Number.MAX_SAFE_INTEGER) };
7748
- const requirement = selectAcceptableRequirement(probe.accepts, anyCeiling);
7749
- if (requirement === void 0) {
7750
- fail(describeUnacceptableAccepts(probe));
7751
- }
7752
- const quote = requirementAmount(requirement);
7753
- if (quote === null || quote <= 0n) {
7754
- fail("the upstream quote is not a positive integer amount");
7755
- }
7756
- let solanaSecretKey = loaded.secrets.solana_secret_key;
7757
- if (solanaSecretKey === void 0 || solanaSecretKey.length === 0) {
7758
- let choice;
7759
- if (options.yes || options.generateWallet) {
7760
- if (!options.generateWallet) {
7761
- fail(
7762
- "this agent has no Solana wallet key. Run interactively to generate/import one, or pass --generate-wallet (a money-holding key is never created silently under --yes)."
7763
- );
7764
- }
7765
- choice = "generate";
7766
- } else {
7767
- const { walletChoice } = await inquirer.prompt([
7768
- {
7769
- type: "list",
7770
- name: "walletChoice",
7771
- message: "The bridge pays the upstream from the agent wallet, but .secrets.json has no solana_secret_key:",
7772
- choices: [
7773
- { name: "Generate a new wallet", value: "generate" },
7774
- { name: "Import an existing one (solana-keygen JSON file or base58)", value: "import" }
7775
- ]
7776
- }
7777
- ]);
7778
- choice = walletChoice;
7779
- }
7780
- if (choice === "generate") {
7781
- const wallet = await generateSolanaWallet();
7782
- solanaSecretKey = wallet.secretKeyBase58;
7783
- } else {
7784
- const { imported } = await inquirer.prompt([
7785
- {
7786
- // Masked: a pasted base58 secret key must not echo to the terminal
7787
- // scrollback (matches every other secret prompt in the CLI). A file
7788
- // path can still be pasted/drag-dropped under the mask.
7789
- type: "password",
7790
- mask: "*",
7791
- name: "imported",
7792
- message: "Path to a solana-keygen JSON file (or paste a base58 secret key):"
7793
- }
7794
- ]);
7795
- try {
7796
- solanaSecretKey = await parseImportedSecret(imported);
7797
- } catch (error) {
7798
- fail(error instanceof Error ? error.message : String(error));
7799
- }
7800
- }
7801
- await writeSecrets(
7802
- loaded.dir,
7803
- { ...loaded.secrets, solana_secret_key: solanaSecretKey },
7804
- passphrase
7805
- );
7806
- console.log(" Wallet key saved to .secrets.json");
7807
- }
7808
- const signer = await signerFromSecretKeyBase58(solanaSecretKey);
8683
+ const loaded = await loadAgent(name, cwd, passphrase);
7809
8684
  const solPayment = loaded.yaml.payments.find((entry) => entry.chain === "solana");
7810
- if (solPayment !== void 0 && solPayment.address !== signer.address) {
7811
- fail(
7812
- `wallet invariant violated: elisym.yaml payments[].address (${solPayment.address}) differs from the solana_secret_key address (${signer.address}).
7813
- Revenue must land in the wallet the bridge spends from, or the float never refills.
7814
- Fix: set payments[].address to the key address, or import the matching key.`
7815
- );
7816
- }
7817
- if (solPayment === void 0) {
7818
- console.log(
7819
- ` elisym.yaml has no Solana payments entry; adding address ${signer.address} (devnet).`
7820
- );
7821
- await writeYaml(loaded.dir, {
7822
- ...loaded.yaml,
7823
- payments: [
7824
- ...loaded.yaml.payments,
7825
- { chain: "solana", network: "devnet", address: signer.address }
7826
- ]
7827
- });
7828
- }
7829
- const rpc = createSolanaRpc(getRpcUrl());
7830
- const balance = await fetchUsdcBalance(rpc, address(signer.address));
7831
- let feeBps;
7832
- try {
7833
- const protocolConfig = await getProtocolConfig(rpc, getProtocolProgramId("devnet"), {
7834
- forceRefresh: true
7835
- });
7836
- feeBps = protocolConfig.feeBps;
7837
- } catch (error) {
7838
- const message = error instanceof Error ? error.message : String(error);
7839
- fail(`could not read the on-chain protocol config (fee): ${message}`);
7840
- }
7841
- if (feeBps >= BPS_DENOMINATOR) {
7842
- fail(
7843
- `protocol fee is ${feeBps} bps (>= 100%) - price math would divide by zero or go negative`
7844
- );
7845
- }
7846
- const priceSubunits = computeBridgePriceSubunits(quote, marginBps, feeBps);
7847
- const priceDisplay = new Decimal(priceSubunits.toString()).div(new Decimal(10).pow(USDC_SOLANA_DEVNET.decimals)).toString();
7848
- const protocolFee = calculateProtocolFee(Number(priceSubunits), feeBps);
7849
- const netMargin = priceSubunits - BigInt(protocolFee) - quote;
7850
- const fallbackName = slugFromUrl(parsedUrl);
7851
- const rawName = options.name ?? probe.resource?.serviceName ?? fallbackName;
7852
- const skillName = sanitizeUpstreamText(rawName, LIMITS.MAX_AGENT_NAME_LENGTH);
7853
- if (!/[a-zA-Z0-9]/.test(skillName)) {
7854
- fail(
7855
- `the upstream service name ("${rawName}") has no ASCII letters or digits - pass an explicit --name <skill-name>`
7856
- );
7857
- }
7858
- const candidateDTag = toDTag(skillName);
7859
- const description = sanitizeUpstreamText(
7860
- probe.resource?.description ?? `x402 bridge to ${parsedUrl.hostname}`,
7861
- LIMITS.MAX_DESCRIPTION_LENGTH
7862
- );
7863
- const capabilities = [candidateDTag];
7864
- const skillsDir = join(loaded.dir, "skills");
7865
- const scan = scanExistingSkills(skillsDir, candidateDTag, url);
7866
- let targetDir = join(skillsDir, candidateDTag);
7867
- if (scan.sameUrlDir !== void 0) {
7868
- if (!options.yes) {
7869
- const { update } = await inquirer.prompt([
7870
- {
7871
- type: "confirm",
7872
- name: "update",
7873
- message: `Skill "${scan.sameUrlName}" already bridges this URL - regenerate it with the fresh quote?`,
7874
- default: true
7875
- }
7876
- ]);
7877
- if (!update) {
7878
- fail("aborted");
7879
- }
7880
- }
7881
- targetDir = scan.sameUrlDir;
7882
- } else if (scan.dTagCollision !== void 0) {
7883
- fail(
7884
- `the generated name "${skillName}" collides with existing skill "${scan.dTagCollision}" (both map to d-tag "${candidateDTag}", which would replace its live discovery card) - pass a distinct --name`
7885
- );
7886
- } else if (existsSync(targetDir)) {
7887
- fail(`skill directory ${targetDir} already exists - pass a distinct --name`);
7888
- }
7889
- let queryParam = options.queryParam;
7890
- if (method === "GET" && queryParam === void 0 && !options.yes) {
7891
- const { param } = await inquirer.prompt([
7892
- {
7893
- type: "input",
7894
- name: "param",
7895
- message: "GET upstream: which query parameter should carry the buyer input? (empty = the skill takes no input)"
7896
- }
7897
- ]);
7898
- queryParam = param.trim() === "" ? void 0 : param.trim();
7899
- }
7900
- if (queryParam !== void 0 && parsedUrl.searchParams.has(queryParam)) {
7901
- fail(`--query-param "${queryParam}" collides with a parameter already present in the URL`);
7902
- }
7903
- if (method === "POST" && queryParam !== void 0) {
7904
- fail("--query-param only applies to GET upstreams (POST maps the input to the request body)");
7905
- }
7906
- const noInput = method === "GET" && queryParam === void 0;
7907
- const feePercent = new Decimal(feeBps).div(100).toString();
7908
- const marginPercent = new Decimal(marginBps).div(100).toString();
7909
- console.log("");
7910
- console.log(` ${chalk.bold(skillName)}`);
7911
- console.log(` ${description}`);
7912
- console.log("");
7913
- console.log(
7914
- ` Upstream ${url} (${method}${queryParam !== void 0 ? `, input in ?${queryParam}=` : ""}${noInput ? ", no buyer input" : ""})`
7915
- );
7916
- console.log(` Quote ${formatAssetAmount(USDC_SOLANA_DEVNET, quote)}`);
7917
- console.log(
7918
- ` Your price ${formatAssetAmount(USDC_SOLANA_DEVNET, priceSubunits)} (margin ${marginPercent}% + protocol fee ${feePercent}%)`
7919
- );
7920
- console.log(` Net margin ${formatAssetAmount(USDC_SOLANA_DEVNET, netMargin)} per job`);
7921
- console.log(
7922
- ` Float ${formatAssetAmount(USDC_SOLANA_DEVNET, balance)} in ${signer.address}`
7923
- );
7924
- if (balance < quote) {
7925
- console.log(
7926
- chalk.yellow(
7927
- ` ! Float below one upstream quote - fund the wallet with devnet USDC: ${CIRCLE_FAUCET_URL}`
7928
- )
7929
- );
7930
- }
7931
- console.log(` Skill dir ${targetDir}`);
7932
- console.log("");
7933
- if (!options.yes) {
7934
- const { confirmed } = await inquirer.prompt([
7935
- { type: "confirm", name: "confirmed", message: "Write the skill?", default: true }
7936
- ]);
7937
- if (!confirmed) {
7938
- fail("aborted");
7939
- }
8685
+ if (!solPayment?.address) {
8686
+ console.error("Solana address not configured for this agent.");
8687
+ process.exit(1);
7940
8688
  }
7941
- const content = buildSkillMd({
7942
- skillName,
7943
- description,
7944
- capabilities,
7945
- priceDisplay,
7946
- url,
7947
- method,
7948
- queryParam,
7949
- quote,
7950
- marginPercent,
7951
- feePercent
7952
- });
7953
- const roundTrip = parseSkillMd(content);
7954
- validateSkillFrontmatter(roundTrip.frontmatter, roundTrip.systemPrompt, {
7955
- allowFreeSkills: false,
7956
- allowX402Skills: true
7957
- });
7958
- await mkdir(targetDir, { recursive: true });
7959
- await writeFile(join(targetDir, "SKILL.md"), content, "utf-8");
7960
- await ensureGitignoreHasX402Entries(dirname(loaded.dir));
8689
+ const rpcUrl = getRpcUrl(solPayment.network);
8690
+ const rpc = createSolanaRpc(rpcUrl);
8691
+ const walletAddress = address(solPayment.address);
8692
+ const { value: balance } = await rpc.getBalance(walletAddress).send();
8693
+ const usdcBalance = await fetchUsdcBalance(rpc, walletAddress);
7961
8694
  console.log(`
7962
- Wrote ${join(targetDir, "SKILL.md")}`);
7963
- if (balance < quote) {
7964
- console.log(` Next: fund the float (devnet USDC): ${CIRCLE_FAUCET_URL}`);
7965
- }
7966
- console.log(` Start the agent: npx @elisym/cli start ${agentName}
8695
+ Agent: ${name}`);
8696
+ console.log(` Network: ${solPayment.network}`);
8697
+ console.log(` Address: ${solPayment.address}`);
8698
+ console.log(` SOL balance: ${formatSol(Number(balance))} (${balance} lamports)`);
8699
+ console.log(` USDC balance: ${formatAssetAmount(USDC_SOLANA_DEVNET, usdcBalance)}
7967
8700
  `);
7968
8701
  }
7969
8702
  function readPackageVersion() {
@@ -8039,6 +8772,14 @@ program.command("list").description("List all agents (project-local and home-glo
8039
8772
  })
8040
8773
  );
8041
8774
  program.command("wallet [name]").description("Show wallet balance").action(safe(cmdWallet));
8775
+ program.command("delegate-key [name]").description("Generate, show, or rotate the agent's dedicated delegate signing key (spl-approve)").option("--show", "Print the existing delegate pubkey/address only").option(
8776
+ "--import",
8777
+ "Import an existing key (solana-keygen JSON path or base58) instead of generating"
8778
+ ).option("--rotate", "Replace an existing delegate key (invalidates outstanding owner approvals)").option("--yes", "Skip confirmation prompts (requires an explicit agent argument)").action(
8779
+ safe(async (name, options) => {
8780
+ await cmdDelegateKey(name, options);
8781
+ })
8782
+ );
8042
8783
  var identity = program.command("identity").description("Link, verify, and unlink external identities (GitHub, X, website)");
8043
8784
  identity.command("link <platform> [agent]").description(
8044
8785
  "Link a github|x|website identity: prints the NIP-39 proof template, verifies the proof live, writes elisym.yaml, and publishes the claim (kind 10011 / kind-0 nip05)"