@paydlux/agent 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,9 @@ real money has already been drained this way.
7
7
 
8
8
  Paydlux puts the guardrails **on the ledger** instead of in your code.
9
9
 
10
+ **Homepage: [paydlux.com](https://paydlux.com)** — product, pricing and the
11
+ live demo. Source: this repo. Docs and setup: below.
12
+
10
13
  ## The Stellar path (primary — no smart contract, no faucet, no gas fight)
11
14
 
12
15
  There is no contract to audit. The whole mechanism is three native Stellar
@@ -30,6 +33,7 @@ injection cannot talk its way past a signature check.
30
33
  stellar/vault.ts create a 2-of-2 vault, register mandates, settle, revoke
31
34
  stellar/gateway.ts HTTP 402 resource server; serves only after the ledger agrees
32
35
  stellar/agent.ts the buyer side — one class, one method call
36
+ stellar/mcp.ts the same buyer side as an MCP server, for any agent client
33
37
  stellar/desk.ts approval queue: a human decides, in the clear
34
38
  stellar/portal.ts the page that human looks at
35
39
  stellar/policy.ts daily budget / seller allowlist (the part a ledger can't count)
@@ -75,6 +79,57 @@ What the desk enforces once it is public (`npx tsx stellar/tls-check.ts`, 19 che
75
79
 
76
80
  No wallet, no faucet, no config.
77
81
 
82
+ ### MCP server: your agent calls Paydlux instead of a wallet
83
+
84
+ The buyer side also speaks [Model Context Protocol](https://modelcontextprotocol.io),
85
+ so an agent in Claude, Cursor, LobeHub, Codex or any other MCP client gets Paydlux's
86
+ tools directly — no wallet, no signing library, no payment code in the agent:
87
+
88
+ ```bash
89
+ npx -y @paydlux/agent mcp --file /absolute/path/to/paydlux-vault.json
90
+ ```
91
+
92
+ Add it to the client the same way you add any stdio server:
93
+
94
+ ```json
95
+ {
96
+ "mcpServers": {
97
+ "paydlux": {
98
+ "command": "npx",
99
+ "args": ["-y", "@paydlux/agent", "mcp", "--file", "/absolute/path/to/paydlux-vault.json"]
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ | tool | what the agent gets |
106
+ |---|---|
107
+ | `paydlux_status` | which vault, which token, live balance, who can sign, how many mandates are live |
108
+ | `paydlux_payment_rules` | the ceiling the human set — plus what the ledger says is already spent today |
109
+ | `paydlux_check_payment` | dry run: would *this* amount to *this* seller pass, and which rule breaks if not |
110
+ | `paydlux_buy` | pay a 402-gated seller. If a human has to approve, the answer is a handle and a status URL |
111
+ | `paydlux_payment_status` | has the human decided yet |
112
+ | `paydlux_recent_payments` | the last payments in/out, with transaction hashes an explorer can check |
113
+ | `paydlux_escrow_status` / `paydlux_pending_escrows` | what is locked in escrow, and who can still take it back |
114
+
115
+ Two things this server deliberately cannot do:
116
+
117
+ - **It cannot release money on its own.** It loads the agent key and nothing
118
+ else — one signer of weight 1 against a release threshold of 2. There is no
119
+ flag that accepts the principal or approver key, so it cannot be talked into
120
+ using a key it never holds. A payment the mandate does not cover comes back as
121
+ `needs_approval`, with the handle and the desk link for the human.
122
+ - **It cannot read a carelessly stored key file.** If the vault file is readable
123
+ by other users, the server refuses to start until you `chmod 600` it, and no
124
+ tool result will ever print a secret seed.
125
+
126
+ Run it with `--read-only` and the two tools that touch money disappear from the
127
+ tool listing entirely — useful for an agent that should report on spend but not
128
+ cause any. `npx tsx stellar/mcp-check.ts` drives the server with a real MCP
129
+ client and asserts all of the above (20 checks, testnet).
130
+
131
+ Product page, pricing and the hosted demo: **[https://paydlux.com](https://paydlux.com)**.
132
+
78
133
  ### The human key does not live on the seller's machine
79
134
 
80
135
  The vault needs two signatures. If both keys sit in one process, that is really
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // into their bot.
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import { createServer } from "node:http";
18
- import { execSync } from "node:child_process";
18
+ import { execSync, spawn } from "node:child_process";
19
19
  import { existsSync, readFileSync, writeFileSync, chmodSync, realpathSync } from "node:fs";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { resolve } from "node:path";
@@ -29,6 +29,7 @@ import { DEMO_HOST, DEMO_ROUTE, startDemo } from "./showcase.js";
29
29
  import { DONATE_HOST, DONATE_PORT, startDonate } from "./donate.js";
30
30
  import { claimEscrow, pendingEscrows, prepareEscrowRelease, refundEscrow } from "./escrow.js";
31
31
  import { describeChallenge } from "./phone.js";
32
+ import { start as startMcp } from "./mcp.js";
32
33
  export const VAULT_FILE_DEFAULT = "paydlux-vault.json";
33
34
  const arg = (name) => {
34
35
  const i = process.argv.indexOf(`--${name}`);
@@ -421,7 +422,7 @@ async function sell() {
421
422
  bad(`port ${demoPort} (showcase) is already in use by ${who}.`);
422
423
  await startDemo({
423
424
  port: demoPort, price, network, vault: vault.address, token: data.token.code,
424
- explorer: data.explorer ?? "", gateway: gw,
425
+ explorer: data.explorer ?? "", gateway: gw, route: uri, payee,
425
426
  onKeys: (pubs) => { gw.agentKeys = [pub(keys.agent), ...pubs]; },
426
427
  });
427
428
  log(`showcase is listening on 127.0.0.1:${demoPort} for https://${DEMO_HOST}`);
@@ -504,6 +505,257 @@ ${flag("donate") ? ` donations https://${DONATE_HOST} (they pay YOU from THE
504
505
  process.on("SIGINT", bye);
505
506
  process.on("SIGTERM", bye);
506
507
  }
508
+ /**
509
+ * paydlux demo — the funder meeting, as one command.
510
+ *
511
+ * `sell` is the right command for a seller: it takes flags, and it refuses to
512
+ * start when anything is off (port taken, budget already spent). That is correct
513
+ * for a seller and fatal in front of an investor, who is watching a terminal go
514
+ * red thirty seconds before the money shot.
515
+ *
516
+ * This is a preset, not a second implementation. It fixes the two things that
517
+ * actually break a live demo, then runs the SAME sell() — same mandate, same
518
+ * desk, same ledger, same phone signature:
519
+ *
520
+ * 1. A port already held by Paydlux is not an error. It means the demo is
521
+ * already running, so print the links and open the tabs instead of dying.
522
+ * 2. A daily budget already spent today IS fatal (the mandate would refuse
523
+ * every payment), so raise it, save it to the vault file, and say so out
524
+ * loud. Raising the *daily* counter is safe: the per-payment ceiling is
525
+ * still pinned into the signed transaction hash.
526
+ *
527
+ * It then opens the two tabs and prints the runbook, so nobody has to remember
528
+ * a URL or a portal key while someone is watching.
529
+ */
530
+ async function demo() {
531
+ const path = resolve(process.cwd(), arg("file") ?? VAULT_FILE_DEFAULT);
532
+ if (!existsSync(path))
533
+ bad(`no vault here. Run: paydlux setup`);
534
+ const data = JSON.parse(readFileSync(path, "utf8"));
535
+ const network = (data.network ?? "testnet");
536
+ const price = arg("price") ?? data.policy?.defaultPrice ?? "1";
537
+ const ports = [
538
+ Number(arg("port") ?? 4021), Number(arg("desk") ?? 4022),
539
+ Number(arg("demo-port") ?? 4023),
540
+ ];
541
+ // The donation door (4024) is deliberately NOT in this list and NOT started
542
+ // by the demo. It runs against the MAINNET vault, where real people send real
543
+ // USDC. A demo preset that restarted it would point a public money address at
544
+ // a testnet vault, and a donation to a testnet account is gone forever.
545
+ // 1. Something already on those ports? Two different things happened in real
546
+ // meetings and neither may end in a red error:
547
+ // a) the demo IS up and answering — reuse it, just open the tabs;
548
+ // b) a leftover Paydlux (a donation door from this morning, a seller that
549
+ // lost its tunnel) is squatting on one port — the public URLs then 530
550
+ // and `sell` refuses to start. Stop the leftover, say exactly what was
551
+ // stopped, and start a clean demo.
552
+ const live = await surfacesLive();
553
+ if (live.desk && live.demo && !flag("restart") && await surfacesServe(path)) {
554
+ const key = data.desk?.portalKey;
555
+ log("the demo is already running and answering — nothing to start");
556
+ openUrl(`https://${DEMO_HOST}`);
557
+ if (key)
558
+ openUrl(`https://${PUBLIC_HOST}/phone/?k=${key}`);
559
+ demoCard(`https://${PUBLIC_HOST}`, key, data);
560
+ return;
561
+ }
562
+ if (live.desk && live.demo) {
563
+ // Answering, but on a different vault. desk.paydlux.com and demo.paydlux.com
564
+ // are ONE address: a testnet demo and a mainnet demo cannot both be live on
565
+ // it. Reusing whichever happens to be up is how a founder ends up showing
566
+ // play money while believing the real vault is on the screen — or the other
567
+ // way round, with real money moving during a "safe" demo. Stop it, say so,
568
+ // and start the one that was asked for.
569
+ log(`something else is answering ${DEMO_HOST} — stopping it so the ${network} demo can take the address`);
570
+ }
571
+ await clearDemoPorts(ports);
572
+ // 2. Budget headroom. `sell` refuses to start when the ledger says today's
573
+ // budget is gone, and the demo would then die at the worst possible moment.
574
+ // Seed the counter from the ledger here and lift the ceiling if needed.
575
+ // NB: `payee` in the vault file is an object ({address, seed}) when `setup`
576
+ // generated one, and a bare address string when the seller passed --payee.
577
+ // Reading only the string form silently skips this check — which is exactly
578
+ // how the demo died once, on "1.00 of the 50 daily budget is already spent".
579
+ const payeeAddr = typeof data.payee === "string" ? data.payee : data.payee?.address;
580
+ const daily = Number(data.policy?.dailyBudget ?? 50);
581
+ if (typeof payeeAddr === "string" && /^G[A-Z2-7]{55}$/.test(payeeAddr)) {
582
+ try {
583
+ const vault = await vaultHandle(data, network);
584
+ const spent = Number(await vault.paidToday(payeeAddr));
585
+ // Enough headroom for ten more demo payments, whatever the price is.
586
+ const want = spent + Math.max(10, Math.ceil(Number(price) * 10));
587
+ if (daily < want) {
588
+ data.policy = { ...(data.policy ?? {}), savedByOwner: true, dailyBudget: Math.ceil(want) };
589
+ writeFileSync(path, JSON.stringify(data, null, 2));
590
+ chmodSync(path, 0o600);
591
+ log(`today's spend is ${spent.toFixed(2)} of ${daily}; raised the daily budget to ${data.policy.dailyBudget} so the demo has room`);
592
+ log(` per-payment ceiling unchanged (${data.policy.maxPerPayment ?? "?"}) — that one is pinned into the signed transaction hash`);
593
+ }
594
+ }
595
+ catch (e) {
596
+ // Do not block the demo on this check. If the ledger really is
597
+ // unreachable, sell() will say so plainly before it starts anything.
598
+ log(`could not pre-check today's spend (${e?.message ?? e}) — starting anyway`);
599
+ }
600
+ }
601
+ // 3. Run the real thing. sell() reads its options from process.argv, so the
602
+ // preset is expressed there rather than by duplicating the seller's startup.
603
+ const extra = ["sell", "--split", "--tls", "--showcase",
604
+ "--price", price, "--route", "/market/data", "--file", path];
605
+ for (const f of ["max", "daily", "port", "desk", "demo-port", "donate-port"]) {
606
+ const v = arg(f);
607
+ if (v !== undefined)
608
+ extra.push(`--${f}`, v);
609
+ }
610
+ process.argv = [process.argv[0], process.argv[1], ...extra];
611
+ // Open the tabs as soon as the desk actually answers — not before, because a
612
+ // tab that loads an error page is what a funder remembers. Scheduled rather
613
+ // than awaited: sell() never returns, it stays up serving payments.
614
+ const key = data.desk?.portalKey;
615
+ demoCard(`https://${PUBLIC_HOST}`, key, data);
616
+ if (!flag("no-open")) {
617
+ setTimeout(async () => {
618
+ if (await tunnelLive()) {
619
+ openUrl(`https://${DEMO_HOST}`);
620
+ if (key)
621
+ openUrl(`https://${PUBLIC_HOST}/phone/?k=${key}`);
622
+ }
623
+ }, 1000);
624
+ }
625
+ await sell();
626
+ }
627
+ /** Are the public URLs actually answering? A port can be held by a process
628
+ * whose tunnel died, which is worse than no process at all: the browser gets
629
+ * a Cloudflare 530 and the founder has no idea why. */
630
+ async function surfacesLive() {
631
+ const get = async (url) => {
632
+ try {
633
+ const r = await fetch(url, { signal: AbortSignal.timeout(4000) });
634
+ return r.status < 500;
635
+ }
636
+ catch {
637
+ return false;
638
+ }
639
+ };
640
+ const [desk, demo] = await Promise.all([
641
+ get(`https://${PUBLIC_HOST}/healthz`), get(`https://${DEMO_HOST}/`),
642
+ ]);
643
+ return { desk, demo };
644
+ }
645
+ /**
646
+ * Is the Paydlux answering those ports the SAME vault that was asked for?
647
+ *
648
+ * The public addresses are singletons: desk.paydlux.com and demo.paydlux.com can
649
+ * only carry one vault at a time, and the testnet demo and the mainnet demo use
650
+ * the same two. "It is already running" is therefore not enough — the thing that
651
+ * is running may be the other network. Answering a funder with play money while
652
+ * saying it is the real vault (or moving real money during what everyone believes
653
+ * is a safe demo) is the worst failure this command can have, so the reuse path
654
+ * checks the vault file of the process actually holding the ports.
655
+ */
656
+ async function surfacesServe(path) {
657
+ const want = resolve(process.cwd(), path);
658
+ for (const port of [Number(arg("desk") ?? 4022), Number(arg("port") ?? 4021)]) {
659
+ const who = await portHolder(port);
660
+ const pid = Number((who?.match(/pid (\d+)/) ?? [])[1]);
661
+ if (!pid)
662
+ return false;
663
+ // -ww: a truncated command line would hide the --file argument and make this
664
+ // say "different vault" about the very demo we are asking to reuse.
665
+ let cmd = "";
666
+ try {
667
+ cmd = execSync(`ps -ww -o command= -p ${pid} 2>/dev/null`, { encoding: "utf8" }).trim();
668
+ }
669
+ catch {
670
+ return false;
671
+ }
672
+ const served = (cmd.match(/--file[= ](\S+)/) ?? [])[1] ?? VAULT_FILE_DEFAULT;
673
+ if (resolve(process.cwd(), served) !== want)
674
+ return false;
675
+ }
676
+ return true;
677
+ }
678
+ /**
679
+ * Free the demo's ports when a stale Paydlux is squatting on one.
680
+ *
681
+ * Only Paydlux processes are touched, and only after saying which one and why.
682
+ * Anything else on the port is left alone and reported, because killing a
683
+ * stranger's server to make a demo fit is not our call to make.
684
+ */
685
+ async function clearDemoPorts(ports) {
686
+ for (const port of ports) {
687
+ const who = await portHolder(port);
688
+ if (!who)
689
+ continue;
690
+ if (!/paydlux|cli|tsx|node/i.test(who)) {
691
+ bad(`port ${port} is held by ${who}, which is not Paydlux.\n free it, or move the demo: paydlux demo --desk ${port + 100}`);
692
+ }
693
+ const pid = Number((who.match(/pid (\d+)/) ?? [])[1]);
694
+ if (!pid) {
695
+ log(`port ${port} is held by ${who}; stopping it`);
696
+ continue;
697
+ }
698
+ try {
699
+ const cmd = execSync(`ps -o command= -p ${pid} 2>/dev/null`, { encoding: "utf8" }).trim();
700
+ log(`stopping the stale Paydlux on port ${port} (pid ${pid}): ${cmd.slice(0, 90)}`);
701
+ process.kill(pid, "SIGTERM");
702
+ }
703
+ catch { /* already gone */ }
704
+ // Wait for the port to actually free. SIGTERM to a seller runs its bye()
705
+ // handler, which takes a moment to close listeners and the tunnel.
706
+ for (let i = 0; i < 20; i++) {
707
+ if (!(await portHolder(port)))
708
+ break;
709
+ await new Promise((r) => setTimeout(r, 250));
710
+ }
711
+ const still = await portHolder(port);
712
+ if (still) {
713
+ try {
714
+ process.kill(pid, "SIGKILL");
715
+ }
716
+ catch { /* gone */ }
717
+ log(`port ${port} needed a hard stop`);
718
+ }
719
+ }
720
+ }
721
+ /** The runbook, printed where the founder is already looking. */
722
+ function demoCard(host, key, data) {
723
+ const network = data.network ?? "testnet";
724
+ // The footer used to promise "nothing here costs real money" unconditionally.
725
+ // On the public network that is a lie, and it is the kind of lie that gets a
726
+ // founder spending the vault in front of an investor.
727
+ const real = network !== "testnet";
728
+ console.log(`
729
+ ┌── Paydlux demo · the 5-minute script ─────────────────────────────
730
+ │ 1 https://paydlux.com "an agent buys from a website"
731
+ │ 2 https://${DEMO_HOST} press RUN THE LOOP → 402 → paid
732
+ │ 3 your iPhone hold 1.2s. the key never left it
733
+ │ 4 back on tab 2 step 5 prints the ledger tx hash
734
+ │ 5 ${host}/rules${key ? `?k=${key}` : ""} spending limits, categories
735
+ └───────────────────────────────────────────────────────────────────
736
+ token ${data.token?.code ?? "?"} · ${network} · ${real
737
+ ? "REAL MONEY — each approved loop pays out of this vault"
738
+ : "nothing here costs real money"}
739
+ `);
740
+ }
741
+ /** Open a URL in the default browser. Best-effort: a demo survives it failing. */
742
+ function openUrl(url) {
743
+ try {
744
+ const tool = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
745
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
746
+ const p = spawn(tool, args, { stdio: "ignore", detached: true });
747
+ p.on("error", () => log(`open this yourself: ${url}`));
748
+ p.unref();
749
+ }
750
+ catch {
751
+ log(`open this yourself: ${url}`);
752
+ }
753
+ }
754
+ /** The vault handle `sell` uses, shared so the demo checks the same numbers. */
755
+ async function vaultHandle(data, network) {
756
+ const keys = data.keys ?? {};
757
+ return StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: keys.approver ? pub(keys.approver) : undefined, agent: pub(keys.agent) }, network);
758
+ }
507
759
  /** XLM the vault must hold to exist, versus what it actually has. */
508
760
  async function vaultRent(vault) {
509
761
  const acc = await vault.horizon.loadAccount(vault.address);
@@ -819,7 +1071,19 @@ async function withdraw() {
819
1071
  bad("then run this again — approvals from the phone land automatically.");
820
1072
  }
821
1073
  console.log(`sending ${want} ${data.token.code} from the vault to ${to} …`);
822
- const res = await vault.release(to, String(want), principal, approver, "paydlux withdraw");
1074
+ // A wallet that has never been funded cannot receive a payment — the ledger
1075
+ // answers op_no_destination. For XLM the fix is the createAccount operation,
1076
+ // which costs the same two signatures. For a token there is no fix at all:
1077
+ // that account would need a trustline too, so say so instead of failing cryptically.
1078
+ const exists = await vault.horizon.loadAccount(to).then(() => true).catch(() => false);
1079
+ if (!exists && !vault.isNative) {
1080
+ bad(`${to} has no account on the ledger, so it cannot hold ${data.token.code}.\n send it XLM from any exchange first, then withdraw again.`);
1081
+ }
1082
+ if (!exists)
1083
+ console.log(`${to} does not exist on the ledger yet — this payment creates it.`);
1084
+ const res = exists
1085
+ ? await vault.release(to, String(want), principal, approver, "paydlux withdraw")
1086
+ : await vault.fund(to, String(want), principal, approver, "paydlux withdraw");
823
1087
  console.log(`done. tx ${res?.hash}`);
824
1088
  console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
825
1089
  }
@@ -1076,6 +1340,12 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
1076
1340
  in a claimable balance you cannot touch until --release seconds have
1077
1341
  passed, and the buyer can take it back any time before then. You get paid
1078
1342
  by proving delivery before the timer, not by waiting for it.
1343
+ paydlux demo [--price 1] [--file ./paydlux-vault.json]
1344
+ the funder meeting as one command. Same seller, same desk, same phone
1345
+ signature — but it refuses to fail in front of an audience: if the demo is
1346
+ already running it opens the tabs instead of dying on a taken port, and if
1347
+ today's daily budget is already spent it raises that budget (the
1348
+ per-payment ceiling is untouched) rather than refusing to start.
1079
1349
  paydlux escrow [--release ORD | --refund ESCROW_ID]
1080
1350
  list what is locked for your vault, release one order, or take a buyer's
1081
1351
  money back. Release needs both human keys; on a --split vault it prints
@@ -1095,6 +1365,11 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
1095
1365
  paydlux withdraw --to G… [--all | --amount N]
1096
1366
  take your money back out of the vault. Needs both human keys; the agent
1097
1367
  key can never do this.
1368
+ paydlux mcp [--file ./paydlux-vault.json] [--read-only]
1369
+ serve Paydlux as an MCP server, so an agent in Claude, Cursor, LobeHub or
1370
+ any other MCP client buys through the mandate instead of holding a wallet.
1371
+ This process loads only the agent key — one of two vault signers — so it
1372
+ cannot release money alone. --read-only drops the tools that touch money.
1098
1373
 
1099
1374
  one file holds it all: ./paydlux-vault.json (0600, never commit it)
1100
1375
  `;
@@ -1240,6 +1515,8 @@ export async function main() {
1240
1515
  return setup();
1241
1516
  if (cmd === "sell")
1242
1517
  return sell();
1518
+ if (cmd === "demo")
1519
+ return demo();
1243
1520
  if (cmd === "donate")
1244
1521
  return donate();
1245
1522
  if (cmd === "status")
@@ -1252,6 +1529,8 @@ export async function main() {
1252
1529
  return usdc();
1253
1530
  if (cmd === "escrow")
1254
1531
  return escrow();
1532
+ if (cmd === "mcp")
1533
+ return startMcp();
1255
1534
  bad(`unknown command "${cmd}".\n\n${HELP}`);
1256
1535
  }
1257
1536
  // Run only when this file is the entry point, so importing it in a test is safe.
package/dist/desk.d.ts CHANGED
@@ -112,6 +112,15 @@ export declare class ApprovalDesk {
112
112
  private seq;
113
113
  /** when /rules last saved, so the page can say "saved" without a query flag */
114
114
  private rulesSavedAt;
115
+ /**
116
+ * When the approver app last polled /queue. The desktop portal reads /list and
117
+ * the phone reads /queue, so a hit here means a phone is pointed at THIS desk
118
+ * with THIS link. It is how a Mac can tell "the seller's phone is asleep" from
119
+ * "the phone is still talking to the desk from the last run" — the second one
120
+ * makes a live demo look dead, because the parked payment goes to a queue
121
+ * nobody is looking at.
122
+ */
123
+ private lastPhonePollAt;
115
124
  private server?;
116
125
  cfg: DeskConfig;
117
126
  constructor(cfg: DeskConfig);
package/dist/desk.js CHANGED
@@ -215,6 +215,15 @@ export class ApprovalDesk {
215
215
  seq = 0;
216
216
  /** when /rules last saved, so the page can say "saved" without a query flag */
217
217
  rulesSavedAt = 0;
218
+ /**
219
+ * When the approver app last polled /queue. The desktop portal reads /list and
220
+ * the phone reads /queue, so a hit here means a phone is pointed at THIS desk
221
+ * with THIS link. It is how a Mac can tell "the seller's phone is asleep" from
222
+ * "the phone is still talking to the desk from the last run" — the second one
223
+ * makes a live demo look dead, because the parked payment goes to a queue
224
+ * nobody is looking at.
225
+ */
226
+ lastPhonePollAt = 0;
218
227
  server;
219
228
  cfg;
220
229
  constructor(cfg) { this.cfg = cfg; }
@@ -967,9 +976,30 @@ export class ApprovalDesk {
967
976
  return;
968
977
  }
969
978
  // The approver app's list view. Key-gated like everything else, and it
970
- // carries no paid content and no challenge XDR.
971
- if (url.pathname === "/queue")
979
+ // carries no paid content and no challenge XDR. Polling it is also the
980
+ // proof that a phone is attached to this desk, so note the time.
981
+ if (url.pathname === "/queue") {
982
+ this.lastPhonePollAt = Date.now();
972
983
  return send(200, this.queue());
984
+ }
985
+ // "Is the phone actually connected?" for the Mac's demo app. Counts and
986
+ // public ledger identities only — no payment content, no challenge, no
987
+ // key material — and still behind the portal key like everything else.
988
+ if (url.pathname === "/status") {
989
+ const all = this.list();
990
+ return send(200, {
991
+ ok: true,
992
+ network: this.cfg.vault.network,
993
+ vault: this.cfg.vault.address,
994
+ approver: this.cfg.vault.signers.approver,
995
+ pending: all.filter((r) => r.status === "pending").length,
996
+ awaitingPhone: all.filter((r) => r.status === "awaiting_phone").length,
997
+ settled: all.filter((r) => r.status === "settled").length,
998
+ phoneSeenSecondsAgo: this.lastPhonePollAt
999
+ ? Math.round((Date.now() - this.lastPhonePollAt) / 1000)
1000
+ : null,
1001
+ });
1002
+ }
973
1003
  // The app fetches the pending transaction by handle rather than asking
974
1004
  // the human to paste 400 characters of base64 on a phone.
975
1005
  const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { type Network } from "./vault.ts";
4
+ import { type Policy } from "./policy.ts";
5
+ type Cfg = {
6
+ path: string;
7
+ network: Network;
8
+ vault: string;
9
+ token: {
10
+ code: string;
11
+ issuer?: string;
12
+ };
13
+ payee?: string;
14
+ policy: Partial<Policy>;
15
+ /** the only secret this process holds. One of two vault signers. */
16
+ agentSeed: string;
17
+ principalPub: string;
18
+ approverPub?: string;
19
+ agentPub: string;
20
+ readOnly: boolean;
21
+ };
22
+ export declare function buildServer(cfg: Cfg): McpServer;
23
+ export declare function start(): Promise<void>;
24
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+ // Paydlux as an MCP server: the tools an agent calls when it wants to buy
3
+ // something and a human has to approve the money.
4
+ //
5
+ // npx -y @paydlux/agent mcp --file paydlux-mainnet.json
6
+ //
7
+ // This is the BUYER side of the protocol, and it is deliberately narrow. The
8
+ // only signing key this process ever loads is the agent key: one of two signers
9
+ // on the vault, weight 1 against a release threshold of 2. It can ask, and it
10
+ // can execute a mandate a human already signed. It cannot release money on its
11
+ // own, and there is no flag here that accepts a principal or approver key at
12
+ // all. The guardrail is the ledger, so this server does not have to be trusted.
13
+ //
14
+ // Why the tool set looks like this: an agent's failure mode is not malice, it
15
+ // is confidence. It will retry a refused payment forever, and it will spend a
16
+ // budget it believes is free. So the read-only tools come first — what the
17
+ // human allowed, what is already spent according to the ledger, whether a
18
+ // specific payment would pass — and the one tool that moves anything returns
19
+ // the handle and the human's link rather than pretending the answer is in.
20
+ import { existsSync, readFileSync, statSync, realpathSync } from "node:fs";
21
+ import { resolve } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ import { Keypair } from "@stellar/stellar-sdk";
24
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
+ import { z } from "zod";
27
+ import { StellarVault, assetMatches } from "./vault.js";
28
+ import { PaydluxAgent } from "./agent.js";
29
+ import { PolicyEngine } from "./policy.js";
30
+ import { readEscrow, escrowOutcome, pendingEscrows } from "./escrow.js";
31
+ const HOME = "https://paydlux.com";
32
+ function arg(name) {
33
+ const i = process.argv.indexOf(`--${name}`);
34
+ return i > -1 ? process.argv[i + 1] : undefined;
35
+ }
36
+ const flag = (name) => process.argv.includes(`--${name}`);
37
+ function pub(seed) { return Keypair.fromSecret(seed).publicKey(); }
38
+ /**
39
+ * Load the vault file and keep only what the buyer role needs.
40
+ *
41
+ * The file also holds the principal and approver seeds — the human's half of
42
+ * the vault. They are read once, converted to public keys for the ledger's
43
+ * signer check, and then the parsed object goes out of scope. A process that
44
+ * never holds the human keys cannot be talked into using them, which is the
45
+ * whole point of splitting them in the first place.
46
+ */
47
+ function load(path) {
48
+ if (!existsSync(path)) {
49
+ throw new Error(`no vault file at ${path}. Point --file at a paydlux-*.json created by \`paydlux setup\`.`);
50
+ }
51
+ // Same stance as the CLI: a key file other users can read is not a key file.
52
+ const mode = statSync(path).mode & 0o777;
53
+ if (mode & 0o077) {
54
+ throw new Error(`${path} is readable by others (${mode.toString(8)}). Run: chmod 600 ${path}`);
55
+ }
56
+ const raw = JSON.parse(readFileSync(path, "utf8"));
57
+ const keys = raw.keys ?? {};
58
+ if (!keys.agent)
59
+ throw new Error(`${path} has no keys.agent — this server buys on the agent key's behalf`);
60
+ if (!keys.principal)
61
+ throw new Error(`${path} has no keys.principal — it is not a Paydlux vault file`);
62
+ if (!raw.vault)
63
+ throw new Error(`${path} has no vault address`);
64
+ return {
65
+ path,
66
+ network: (raw.network === "public" ? "public" : "testnet"),
67
+ vault: String(raw.vault),
68
+ token: { code: String(raw.token?.code ?? "XLM"), issuer: raw.token?.issuer },
69
+ payee: raw.payee?.address,
70
+ policy: raw.policy ?? {},
71
+ agentSeed: String(keys.agent),
72
+ principalPub: pub(String(keys.principal)),
73
+ approverPub: keys.approver ? pub(String(keys.approver)) : undefined,
74
+ agentPub: pub(String(keys.agent)),
75
+ readOnly: flag("read-only"),
76
+ };
77
+ }
78
+ // ----------------------------------------------------------------- helpers
79
+ let vaultHandle;
80
+ async function vault(cfg) {
81
+ if (vaultHandle)
82
+ return vaultHandle;
83
+ vaultHandle = await StellarVault.load(cfg.vault, cfg.token.code, cfg.token.issuer, { principal: cfg.principalPub, approver: cfg.approverPub, agent: cfg.agentPub }, cfg.network);
84
+ if (cfg.token.code === "USDC")
85
+ vaultHandle.useUsdc();
86
+ return vaultHandle;
87
+ }
88
+ /**
89
+ * What left this vault today, straight from the ledger, for every payee.
90
+ *
91
+ * StellarVault.paidToday() answers for one payee. An agent asking "how much of
92
+ * my budget is left" means the total, and the in-RAM PolicyEngine counter resets
93
+ * on restart — a budget that reads as unspent after a restart is worse than no
94
+ * budget, so the honest number comes from Horizon. assetMatches is the same
95
+ * single comparison the rest of the codebase uses, so a native-XLM vault is not
96
+ * blind to its own money here.
97
+ */
98
+ async function spentTodayAll(v) {
99
+ const start = new Date();
100
+ start.setUTCHours(0, 0, 0, 0);
101
+ const byPayee = {};
102
+ let total = 0;
103
+ let cursor;
104
+ const base = v.network === "public" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org";
105
+ for (let page = 0; page < 10; page++) {
106
+ const url = `${base}/accounts/${v.address}/payments?order=desc&limit=200${cursor ? `&cursor=${cursor}` : ""}`;
107
+ const res = await fetch(url);
108
+ if (!res.ok)
109
+ throw new Error(`ledger payments query failed: ${res.status}`);
110
+ const recs = (await res.json())?._embedded?.records ?? [];
111
+ if (!recs.length)
112
+ break;
113
+ for (const e of recs) {
114
+ if (new Date(e.created_at).getTime() < start.getTime())
115
+ return { total, byPayee };
116
+ if (e.type !== "payment" && e.type !== "claimable_balance_claim")
117
+ continue;
118
+ if (e.from !== v.address)
119
+ continue; // money leaving the vault only
120
+ if (!assetMatches(v.token, e))
121
+ continue; // the vault's own token, incl. XLM
122
+ total += Number(e.amount);
123
+ byPayee[e.to] = (byPayee[e.to] ?? 0) + Number(e.amount);
124
+ }
125
+ cursor = recs[recs.length - 1].paging_token;
126
+ }
127
+ return { total, byPayee };
128
+ }
129
+ async function recentPayments(v, limit) {
130
+ const base = v.network === "public" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org";
131
+ const res = await fetch(`${base}/accounts/${v.address}/payments?order=desc&limit=${Math.min(limit, 50)}`);
132
+ if (!res.ok)
133
+ throw new Error(`ledger payments query failed: ${res.status}`);
134
+ return ((await res.json())?._embedded?.records ?? []).map((e) => ({
135
+ at: e.created_at, type: e.type, from: e.from, to: e.to,
136
+ amount: e.amount, asset: e.asset_type === "native" ? "XLM" : `${e.asset_code}:${String(e.asset_issuer).slice(0, 6)}…`,
137
+ transaction: e.transaction_hash,
138
+ }));
139
+ }
140
+ /**
141
+ * Last line of defence on output. A Stellar secret seed is an S-prefixed
142
+ * 56-character base32 string; nothing this server returns has any business
143
+ * containing one, so if one ever reaches a tool result it is replaced before it
144
+ * reaches a model's context — and a model's context is a place secrets leak
145
+ * from, usually by being summarised into a public answer.
146
+ */
147
+ function redact(s) {
148
+ return s.replace(/S[A-Z2-7]{55}/g, "[redacted: a secret seed reached an MCP tool result — report this]");
149
+ }
150
+ function text(obj) {
151
+ return { content: [{ type: "text", text: redact(JSON.stringify(obj, null, 2)) }] };
152
+ }
153
+ function fail(reason, extra = {}) {
154
+ return { content: [{ type: "text", text: redact(JSON.stringify({ ok: false, reason, ...extra }, null, 2)) }] };
155
+ }
156
+ /** Sellers answer with the payload an agent asked for; keep tool results small. */
157
+ function clip(data, max = 4000) {
158
+ if (typeof data !== "string")
159
+ return data;
160
+ return data.length > max ? `${data.slice(0, max)}… [+${data.length - max} chars]` : data;
161
+ }
162
+ // ------------------------------------------------------------------ server
163
+ function version() {
164
+ try {
165
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
166
+ }
167
+ catch {
168
+ return "0.0.0";
169
+ }
170
+ }
171
+ export function buildServer(cfg) {
172
+ const policy = new PolicyEngine(cfg.policy);
173
+ const server = new McpServer({ name: "paydlux", version: version() }, {
174
+ instructions: [
175
+ "Paydlux is spend-limited payments for AI agents. The money sits in a 2-of-2",
176
+ "Stellar vault; the agent key is one signer, a human is the other, so an agent",
177
+ "cannot release funds alone. Read paydlux_payment_rules before buying, check",
178
+ "paydlux_check_payment for the specific amount, then paydlux_buy. If a buy",
179
+ "returns needs_approval, tell the human and poll paydlux_payment_status — do not",
180
+ `retry the purchase, and do not open the desk link yourself. ${HOME}`,
181
+ ].join("\n"),
182
+ });
183
+ // ---------------------------------------------------------------- reading
184
+ server.registerTool("paydlux_status", {
185
+ title: "Paydlux vault status",
186
+ description: "Which vault this agent spends from: network, token, live balance, the XLM rent that keeps the account alive, who can sign, and how many spending mandates are live. Read-only.",
187
+ }, async () => {
188
+ const v = await vault(cfg);
189
+ const signers = await v.signerSummary();
190
+ const acc = await v.horizon.loadAccount(v.address);
191
+ const xlm = acc.balances.find((b) => b.asset_type === "native")?.balance ?? "0";
192
+ const min = ((2 + (acc.subentry_count ?? 0)) * 0.5).toFixed(1);
193
+ return text({
194
+ ok: true,
195
+ network: cfg.network === "public" ? "mainnet (real money)" : "testnet",
196
+ vault: v.address,
197
+ token: cfg.token.code,
198
+ balance: await v.balance(),
199
+ xlm_rent: { have: xlm, need: min, short: Number(xlm) < Number(min) },
200
+ thresholds: signers.thresholds,
201
+ signers: signers.signers.map((s) => ({ weight: s.weight, kind: s.type })),
202
+ live_mandates: signers.signers.filter((s) => s.type === "preauth-mandate").length,
203
+ agent_key: cfg.agentPub,
204
+ explorer: `https://stellar.expert/explorer/${cfg.network === "public" ? "account" : "testnet/account"}/${v.address}`,
205
+ about: HOME,
206
+ });
207
+ });
208
+ server.registerTool("paydlux_payment_rules", {
209
+ title: "What the human allowed",
210
+ description: "The spending mandate as the human set it: per-payment ceiling, daily budget, seller allow/deny lists, category ceilings — plus what has actually been spent today according to the ledger, not this process's memory. Call this before buying.",
211
+ }, async () => {
212
+ const v = await vault(cfg);
213
+ const spent = await spentTodayAll(v);
214
+ const p = policy.policy;
215
+ return text({
216
+ ok: true,
217
+ token: cfg.token.code,
218
+ max_per_payment: p.maxPerPayment,
219
+ daily_budget: p.dailyBudget,
220
+ spent_today_ledger: Number(spent.total.toFixed(4)),
221
+ budget_left_today: Number((Number(p.dailyBudget ?? 0) - spent.total).toFixed(4)),
222
+ paid_to: spent.byPayee,
223
+ allowlist: p.allowlist?.length ? p.allowlist : "(any seller)",
224
+ denylist: p.denylist ?? [],
225
+ categories: p.categories ?? {},
226
+ payee_categories: p.payeeCategory ?? {},
227
+ max_live_mandates: p.maxLiveMandates,
228
+ note: "The ceiling is enforced on the ledger, not here. Refusing you early only saves you a failed transaction.",
229
+ });
230
+ });
231
+ server.registerTool("paydlux_check_payment", {
232
+ title: "Would this payment be allowed?",
233
+ description: "Dry-run one payment against the mandate: amount, seller, and anything already approved and waiting. Answers ok plus the headroom left, or which rule it breaks (max_per_payment, daily_budget, allowlist, denylist, category ceiling). No money moves.",
234
+ inputSchema: {
235
+ amount: z.number().positive().describe(`How much to pay, in the vault's token (${cfg.token.code})`),
236
+ payee: z.string().optional().describe("Seller account id (G…, 56 chars). Defaults to the vault's configured payee."),
237
+ committed: z.number().min(0).optional().describe("Amount already approved and still waiting on a signature, so the ceiling counts promises as well as payments"),
238
+ },
239
+ }, async ({ amount, payee, committed }) => {
240
+ const to = payee ?? cfg.payee;
241
+ if (!to)
242
+ return fail("no payee: pass payee, or set payee.address in the vault file");
243
+ const v = await vault(cfg);
244
+ const spent = await spentTodayAll(v);
245
+ // Seed the engine's day counter from the ledger before asking it anything.
246
+ // Its own counter starts empty in every fresh process, so an unseeded check
247
+ // would cheerfully approve a payment that blows a budget already spent —
248
+ // and a restart would reset the ceiling the mandate advertises.
249
+ policy.seedFromLedger(spent.total);
250
+ const verdict = policy.check({ amount, payee: to, committed });
251
+ return text({
252
+ ok: verdict.ok,
253
+ amount, payee: to, token: cfg.token.code,
254
+ rule: verdict.ok ? undefined : verdict.rule,
255
+ why: verdict.ok ? undefined : verdict.detail,
256
+ spent_today_ledger: Number(spent.total.toFixed(4)),
257
+ daily_budget: policy.policy?.dailyBudget,
258
+ max_per_payment: policy.policy?.maxPerPayment,
259
+ });
260
+ });
261
+ server.registerTool("paydlux_recent_payments", {
262
+ title: "Recent payments from the vault",
263
+ description: "The last payments in or out of the vault, straight from the Stellar ledger, with transaction hashes an explorer can verify. Read-only proof of what actually happened.",
264
+ inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("How many records, default 10") },
265
+ }, async ({ limit }) => text({ ok: true, payments: await recentPayments(await vault(cfg), limit ?? 10) }));
266
+ server.registerTool("paydlux_escrow_status", {
267
+ title: "Escrow state",
268
+ description: "Look up a locked escrow by claimable-balance id: amount, when the seller can claim it, and until when the buyer can still take it back without the seller. Also reports who claimed it if it is already settled.",
269
+ inputSchema: { balance_id: z.string().describe("The escrow's claimable balance id (starts with B…)") },
270
+ }, async ({ balance_id }) => {
271
+ const e = await readEscrow(cfg.network, balance_id);
272
+ const outcome = await escrowOutcome(cfg.network, balance_id);
273
+ if (!e)
274
+ return text({ ok: true, escrow: null, outcome, note: "no live escrow with that id — it was claimed, refunded, or never existed" });
275
+ const now = Math.floor(Date.now() / 1000);
276
+ return text({
277
+ ok: true,
278
+ // One timer, two meanings: before releaseAt the buyer can take the money
279
+ // back alone, after it the seller can claim it alone. There is no separate
280
+ // refund deadline on the ledger, so do not invent one here.
281
+ escrow: {
282
+ balance_id: e.balanceId, amount: e.amount, asset: e.asset,
283
+ buyer: e.buyer, vault: e.vault, release_at: e.releaseAt,
284
+ lock_tx: e.txHash, memo: e.memo, claimed: e.claimed,
285
+ },
286
+ buyer_can_refund: !e.claimed && now < e.releaseAt,
287
+ seconds_until_seller_can_claim: Math.max(0, e.releaseAt - now),
288
+ outcome,
289
+ });
290
+ });
291
+ server.registerTool("paydlux_pending_escrows", {
292
+ title: "Escrows this vault is part of",
293
+ description: "Every live escrow the vault is a claimant on, oldest release first, with each one's refund window. Read-only.",
294
+ }, async () => {
295
+ const list = await pendingEscrows(cfg.network, cfg.vault);
296
+ const now = Math.floor(Date.now() / 1000);
297
+ return text({
298
+ ok: true, count: list.length,
299
+ escrows: list.map((e) => ({
300
+ balance_id: e.balanceId, amount: e.amount, asset: e.asset, buyer: e.buyer,
301
+ release_at: e.releaseAt, buyer_can_refund: !e.claimed && now < e.releaseAt,
302
+ })),
303
+ });
304
+ });
305
+ // ---------------------------------------------------------------- buying
306
+ if (!cfg.readOnly) {
307
+ server.registerTool("paydlux_buy", {
308
+ title: "Buy from a 402-gated seller",
309
+ description: "GET (or POST to) a URL that prices itself with HTTP 402. If the seller runs in approval mode the request is parked for a human: the result carries `handle`, `status_url` and the human's `desk` link — report those to the human and poll paydlux_payment_status. Do NOT retry the buy: a second request creates a second approval. A seller demanding a pre-authorised mandate is refused here, because creating that mandate needs the human's keys, which this server does not hold.",
310
+ inputSchema: {
311
+ url: z.string().url().describe("The paid resource, e.g. https://seller.example.com/api/data"),
312
+ body: z.string().optional().describe("Request body; sends POST instead of GET"),
313
+ },
314
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
315
+ }, async ({ url, body }) => {
316
+ const agent = new PaydluxAgent({
317
+ agentKey: cfg.agentSeed,
318
+ network: cfg.network,
319
+ // The agent must not sit inside one MCP call waiting for a person.
320
+ approvalTimeoutMs: 5_000, pollMs: 2_000,
321
+ });
322
+ // Escrow routes are funded by the buyer's own account, never by the agent
323
+ // key on a vault. The key is supplied out of band by whoever runs this
324
+ // server; without it the seller's refusal is reported as-is.
325
+ const buyerKey = process.env.PAYDLUX_BUYER_SECRET;
326
+ const res = await agent.get(url, { body, buyerKey });
327
+ const needsApproval = !res.ok && (res.handle || res.statusUrl);
328
+ return text({
329
+ ok: res.ok,
330
+ status: res.status,
331
+ data: clip(res.data),
332
+ tx: res.tx,
333
+ order: res.order,
334
+ escrow: res.escrow,
335
+ needs_approval: needsApproval || undefined,
336
+ handle: res.handle,
337
+ status_url: res.statusUrl,
338
+ desk: res.desk ? `${res.desk} (open this yourself — it needs the human's portal key)` : undefined,
339
+ reason: res.reason,
340
+ error: res.error,
341
+ requirements: res.requirements,
342
+ next: needsApproval
343
+ ? "tell the human to approve on their desk, then call paydlux_payment_status with status_url and handle"
344
+ : undefined,
345
+ });
346
+ });
347
+ server.registerTool("paydlux_payment_status", {
348
+ title: "Did the human approve?",
349
+ description: "One status check on a parked payment, using the status_url and handle paydlux_buy returned. Polls the seller's gateway, which needs no key. Returns the settled record (with the tx hash) once decided, or the current pending state.",
350
+ inputSchema: {
351
+ status_url: z.string().url().describe("The status_url from paydlux_buy — never the desk link"),
352
+ handle: z.string().describe("The payment handle from paydlux_buy"),
353
+ wait_seconds: z.number().int().min(0).max(120).optional().describe("Keep polling up to this long inside the call. Default 0 = one check"),
354
+ },
355
+ }, async ({ status_url, handle, wait_seconds }) => {
356
+ const agent = new PaydluxAgent({
357
+ agentKey: cfg.agentSeed, network: cfg.network,
358
+ approvalTimeoutMs: (wait_seconds ?? 0) * 1000 + 2_000, pollMs: 2_000,
359
+ });
360
+ const rec = wait_seconds ? await agent.waitForApproval({ statusUrl: status_url }, handle)
361
+ : await fetch(status_url).then((r) => (r.ok ? r.json() : null)).catch(() => null);
362
+ if (!rec)
363
+ return text({ ok: true, status: "pending", handle, note: "no decision yet — check again in a few seconds" });
364
+ return text({ ok: true, decision: rec });
365
+ });
366
+ }
367
+ return server;
368
+ }
369
+ // ------------------------------------------------------------------- main
370
+ export async function start() {
371
+ const file = resolve(process.cwd(), arg("file") ?? process.env.PAYDLUX_VAULT_FILE ?? "paydlux-vault.json");
372
+ const cfg = load(file);
373
+ const server = buildServer(cfg);
374
+ await server.connect(new StdioServerTransport());
375
+ // stdout is the JSON-RPC channel. Any human-facing line goes to stderr.
376
+ console.error(`paydlux mcp: ${cfg.network} vault ${cfg.vault}, token ${cfg.token.code}, tools ${cfg.readOnly ? "read-only" : "read + buy"}. ${HOME}`);
377
+ }
378
+ // Run only when this file is the entry point, so importing it in a check is
379
+ // safe. Compare realpaths, not the invocation string: npm's bin launcher runs
380
+ // this as .../node_modules/.bin/paydlux-mcp, which does not end in .js, so a
381
+ // name-based test silently turns the published command into a no-op.
382
+ const invoked = process.argv[1]
383
+ ? pathToFileURL(existsSync(process.argv[1]) ? realpathSync(process.argv[1]) : process.argv[1]).href
384
+ : undefined;
385
+ if (invoked === import.meta.url) {
386
+ start().catch((e) => { console.error("paydlux mcp failed:", e.message); process.exit(1); });
387
+ }
@@ -42,6 +42,20 @@ export interface DemoConfig {
42
42
  donateHost?: string;
43
43
  /** Called whenever a key is enrolled, so the gateway's accepted set follows. */
44
44
  onKeys?: (pubs: string[]) => void;
45
+ /**
46
+ * The route the showcase sells (e.g. "/market/data"). /demo/last only reports
47
+ * settled payments for THIS route, so a seller's private sales never appear
48
+ * on a public page — a settled demo payment is the proof being shown, not the
49
+ * seller's order book.
50
+ */
51
+ route?: string;
52
+ /**
53
+ * The address the seller gets paid at. /demo/last uses it to read the last
54
+ * real settlement straight off the ledger, so the proof survives a restart of
55
+ * the demo process — an investor visiting at 3am, with the seller asleep,
56
+ * still gets a hash that exists on a public explorer.
57
+ */
58
+ payee?: string;
45
59
  }
46
60
  /**
47
61
  * Serve the showcase in front of a running gateway.
package/dist/showcase.js CHANGED
@@ -103,6 +103,43 @@ function demoPayload(cfg) {
103
103
  proof: cfg.explorer,
104
104
  }, null, 2);
105
105
  }
106
+ /**
107
+ * The last payment this vault really made to the seller, read off the public
108
+ * ledger. The in-memory desk forgets on restart; the ledger does not, so an
109
+ * investor who visits while the seller is asleep still reaches a hash they can
110
+ * open on an explorer instead of a spinner. Cached — one page load should not
111
+ * hammer horizon, and the answer only moves when a payment settles.
112
+ */
113
+ let ledgerProof = null;
114
+ let ledgerProofAt = 0;
115
+ async function lastSettledOnLedger(cfg) {
116
+ if (!cfg.payee)
117
+ return null;
118
+ if (ledgerProof && Date.now() - ledgerProofAt < 5 * 60 * 1000)
119
+ return ledgerProof;
120
+ const host = cfg.network === "public" ? "horizon.stellar.org" : "horizon-testnet.stellar.org";
121
+ try {
122
+ const r = await fetch(`https://${host}/accounts/${cfg.vault}/payments?order=desc&limit=25`, { signal: AbortSignal.timeout(4000) });
123
+ if (!r.ok)
124
+ return null;
125
+ const records = ((await r.json())?._embedded?.records) ?? [];
126
+ for (const p of records) {
127
+ // horizon calls the hash `transaction_hash`; a record without one is not a
128
+ // proof, so it is skipped rather than printed as "undefined".
129
+ if (p?.to !== cfg.payee || !(Number(p.amount) > 0) || !p.transaction_hash)
130
+ continue;
131
+ ledgerProof = {
132
+ at: Date.parse(p.created_at) || 0,
133
+ price: String(p.amount),
134
+ tx: String(p.transaction_hash),
135
+ };
136
+ ledgerProofAt = Date.now();
137
+ return ledgerProof;
138
+ }
139
+ }
140
+ catch { /* no ledger reachable: the page still works, just without the extra proof */ }
141
+ return null;
142
+ }
106
143
  /**
107
144
  * Serve the showcase in front of a running gateway.
108
145
  *
@@ -193,10 +230,32 @@ export function startDemo(cfg) {
193
230
  res.end(JSON.stringify({ ok: true, until: ok.until, vault: cfg.vault }));
194
231
  return;
195
232
  }
233
+ // A visitor watching alone cannot finish step 4 — the seller's phone is the
234
+ // only thing that can sign it, and the seller may be asleep. The loop is
235
+ // worthless as a demo if it ends at "waiting", so hand them the most recent
236
+ // payment this route actually settled: a hash they can open on the explorer
237
+ // without trusting this page. Nothing else about the payment is returned —
238
+ // a tx hash is already public on the ledger, the buyer and the content are
239
+ // not.
240
+ if (p === "/demo/last") {
241
+ const done = cfg.gateway.desk.list()
242
+ .filter((r) => r.status === "settled" && r.tx && (!cfg.route || r.uri === cfg.route))
243
+ .sort((a, b) => b.at - a.at)[0];
244
+ const proof = done
245
+ ? { at: done.at, price: done.price, tx: done.tx }
246
+ : await lastSettledOnLedger(cfg);
247
+ res.writeHead(200, {
248
+ "content-type": "application/json",
249
+ "cache-control": "no-store",
250
+ "x-content-type-options": "nosniff",
251
+ });
252
+ res.end(JSON.stringify(proof ?? { none: true }));
253
+ return;
254
+ }
196
255
  // Everything else belongs to the gateway: the paid route, /pay/status.
197
256
  // Only demo paths are exposed here, so pointing the tunnel at this port
198
257
  // cannot reach a seller's private routes by guessing a URI.
199
- if (!(p === DEMO_ROUTE || p === "/pay/status" || p === "/demo/terms")) {
258
+ if (!(p === DEMO_ROUTE || p === "/pay/status" || p === "/demo/terms" || p === "/demo/last")) {
200
259
  res.writeHead(404, { "content-type": "application/json" });
201
260
  res.end(JSON.stringify({ error: "not found", routes: [DEMO_ROUTE] }));
202
261
  return;
@@ -244,6 +303,7 @@ h1{font-size:30px;margin:0 0 6px;letter-spacing:-.01em}
244
303
  pre{margin:10px 0 0;padding:10px 12px;background:#0f1213;border:1px solid var(--line);border-radius:8px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word;color:#cfd6d0}
245
304
  button{font:inherit;padding:11px 18px;border-radius:9px;border:1px solid var(--gold);background:var(--gold);color:#1a1408;font-weight:600;cursor:pointer;margin:22px 0 0}
246
305
  button[disabled]{opacity:.45;cursor:default}
306
+ .note{margin:12px 0 0;color:var(--dim);font-size:14px}
247
307
  .door{margin:30px 0 0;border:1px solid var(--line);border-left:3px solid var(--gold);background:#1a1f21;border-radius:10px;padding:18px 20px}
248
308
  .door .q{margin:0 0 6px;font-weight:600}
249
309
  .door .a{margin:0;color:var(--dim);font-size:15px}
@@ -275,8 +335,8 @@ on a phone, and the ledger settles it. You are about to drive that loop.</p>
275
335
  <div class="step" id="s3"><h3><span class="n">3</span>Request the payment</h3>
276
336
  <p>The browser signs the request — not the payment — and sends it. The seller parks it for a human.</p>
277
337
  <pre id="o3">not run</pre></div>
278
- <div class="step" id="s4"><h3><span class="n">4</span>A human decides</h3>
279
- <p>The seller sees the exact payment on their phone and holds for 1.2 seconds to approve. Nothing here can skip that.</p>
338
+ <div class="step" id="s4"><h3><span class="n">4</span>A human decides — and it isn't you</h3>
339
+ <p>The buyer half ends at step 3. From here the <b>seller</b> decides: the parked payment appears on their phone with the amount, the payee and the deadline, and a person holds the button for 1.2 seconds. The key that signs it never leaves that device, so no code — including this page — can do it for them. The app only checks the queue while it is open on the screen, so a phone in a pocket signs nothing: if nobody is holding it, this step waits. That is the product, not a bug.</p>
280
340
  <pre id="o4">not run</pre></div>
281
341
  <div class="step" id="s5"><h3><span class="n">5</span>The ledger proves it</h3>
282
342
  <p>Same request, now served. The transaction hash is public and verifiable without trusting this page.</p>
@@ -284,6 +344,9 @@ on a phone, and the ledger settles it. You are about to drive that loop.</p>
284
344
  </div>
285
345
 
286
346
  <button id="go">Run the loop</button>
347
+ <p class="note">You are the <b>buyer</b> here. Steps 1–3 happen in your browser. Step 4 happens on the
348
+ seller's phone — if nobody is holding it, this page will show you the last payment this exact loop
349
+ settled, with a hash you can open on the explorer.</p>
287
350
 
288
351
  <div class="door">
289
352
  <p class="q">That was practice. Nothing you just did cost anything.</p>
@@ -312,6 +375,8 @@ function demoScript() {
312
375
  const el = (id) => document.getElementById(id);
313
376
  const say = (n, txt) => { el("o" + n).textContent = txt; el("s" + n).classList.add("done"); };
314
377
  const PAY = "x-payment";
378
+ let NET = "testnet"; // which explorer the proof links point at
379
+ let TOKEN = ""; // the token the demo is priced in, for readable amounts
315
380
 
316
381
  async function main() {
317
382
  const btn = el("go"); btn.disabled = true;
@@ -321,6 +386,8 @@ function demoScript() {
321
386
  try {
322
387
  const r = await fetch(ROUTE, { cache: "no-store" });
323
388
  terms = JSON.parse(r.headers.get(PAY) || "null");
389
+ NET = String((terms && terms.network) || "testnet").replace("stellar:", "");
390
+ TOKEN = String((terms && terms.token && terms.token.code) || "");
324
391
  say(1, "HTTP " + r.status + "\\n" + JSON.stringify(terms, null, 2));
325
392
  if (r.status !== 402) { say(1, "HTTP " + r.status + " — this showcase is not asking for payment right now"); return; }
326
393
  } catch (e) { say(1, "request failed: " + e); return; }
@@ -352,8 +419,18 @@ function demoScript() {
352
419
  say(3, "HTTP 202 — parked as " + (parked.handle || "?") + "\\n" + (parked.reason || ""));
353
420
 
354
421
  // 4 — wait for a person. The page polls the seller's own status route.
355
- say(4, "waiting for the seller's phone…");
422
+ // Say plainly what is happening: the seller's phone is the only thing that
423
+ // can sign this, and a visitor on their own laptop cannot make that happen.
424
+ // Bare silence here reads as a broken demo.
425
+ say(4, "parked. Now the seller's phone decides — that signature cannot come from this browser.\\nwaiting…");
356
426
  const seen = await poll(parked.statusUrl);
427
+ if (seen.status === "timed out waiting for a human") {
428
+ el("o4").textContent = "nobody signed, so nothing was paid — exactly as designed.\\n"
429
+ + "Run the loop again when the seller is at their phone.";
430
+ await showLast();
431
+ btn.disabled = false;
432
+ return;
433
+ }
357
434
  say(4, "status: " + seen.status + (seen.reason ? "\\n" + seen.reason : ""));
358
435
  if (seen.status !== "settled" && seen.status !== "paid") { btn.disabled = false; return; }
359
436
 
@@ -375,11 +452,29 @@ function demoScript() {
375
452
  for (let i = 0; i < 150; i++) {
376
453
  const j = await (await fetch(u, { cache: "no-store" })).json();
377
454
  if (j.status && j.status !== "pending" && j.status !== "awaiting_phone") return j;
455
+ // ~16 s with nobody at the phone: show that this loop does settle, instead
456
+ // of letting a visitor sit on a spinner and conclude the product is fake.
457
+ if (i === 8) await showLast();
378
458
  await new Promise((r) => setTimeout(r, 2000));
379
459
  }
380
460
  return { status: "timed out waiting for a human" };
381
461
  }
382
462
 
463
+ /**
464
+ * The most recent payment THIS route settled, as a public hash. It is not a
465
+ * substitute for the live approval — it is labelled as a different payment —
466
+ * it is the answer to "okay, but has this ever actually happened?".
467
+ */
468
+ async function showLast() {
469
+ try {
470
+ const last = await (await fetch("/demo/last", { cache: "no-store" })).json();
471
+ if (!last || !last.tx) return;
472
+ el("o4").textContent += "\\n\\nthe same loop, settled for real on " + new Date(last.at).toLocaleString()
473
+ + " (" + Number(last.price) + (TOKEN ? " " + TOKEN : "") + ") — verify it without this page:\\nhttps://stellar.expert/explorer/"
474
+ + NET + "/transaction/" + last.tx;
475
+ } catch { /* the demo still works without the extra proof */ }
476
+ }
477
+
383
478
  el("go").addEventListener("click", main);
384
479
  })();`;
385
480
  }
package/dist/vault.d.ts CHANGED
@@ -171,6 +171,19 @@ export declare class StellarVault {
171
171
  * so a stolen agent key cannot use it and a stolen phone cannot use it alone.
172
172
  */
173
173
  release(payee: string, amount: Amount, principal: Keypair, approver: Keypair, memo?: string): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
174
+ /**
175
+ * Create an account that does not exist yet, with a starting balance. Same two
176
+ * signatures as release(), because it is the vault's money moving.
177
+ *
178
+ * This exists because a plain payment to an address that has never been
179
+ * funded is refused by the ledger (`op_no_destination`) — proven on the mainnet
180
+ * vault when topping up the demo's agent account. So "send XLM to a brand new
181
+ * wallet" (the demo agent, a seller's fresh payee, an owner's new cold
182
+ * account) needs createAccount, not payment. Only XLM can do this: a token
183
+ * payment to an unfunded account can never work, because that account would
184
+ * also need a trustline.
185
+ */
186
+ fund(destination: string, amount: Amount, principal: Keypair, approver: Keypair, memo?: string): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
174
187
  /**
175
188
  * Kill every outstanding mandate at once. Any accepted transaction bumps the
176
189
  * vault's sequence, which invalidates all pre-authorized hashes pointing at a
package/dist/vault.js CHANGED
@@ -395,6 +395,28 @@ export class StellarVault {
395
395
  b.addMemo(Memo.text(memo));
396
396
  return sub(this.horizon, PASSPHRASE[this.network], b.build(), principal, approver);
397
397
  }
398
+ /**
399
+ * Create an account that does not exist yet, with a starting balance. Same two
400
+ * signatures as release(), because it is the vault's money moving.
401
+ *
402
+ * This exists because a plain payment to an address that has never been
403
+ * funded is refused by the ledger (`op_no_destination`) — proven on the mainnet
404
+ * vault when topping up the demo's agent account. So "send XLM to a brand new
405
+ * wallet" (the demo agent, a seller's fresh payee, an owner's new cold
406
+ * account) needs createAccount, not payment. Only XLM can do this: a token
407
+ * payment to an unfunded account can never work, because that account would
408
+ * also need a trustline.
409
+ */
410
+ async fund(destination, amount, principal, approver, memo) {
411
+ if (!this.isNative)
412
+ throw new Error("only XLM can create an account; a token needs a trustline first");
413
+ const b = (await this.tb(this.address))
414
+ .addOperation(Operation.createAccount({ destination, startingBalance: amount }))
415
+ .setTimeout(300);
416
+ if (memo)
417
+ b.addMemo(Memo.text(memo));
418
+ return sub(this.horizon, PASSPHRASE[this.network], b.build(), principal, approver);
419
+ }
398
420
  /**
399
421
  * Kill every outstanding mandate at once. Any accepted transaction bumps the
400
422
  * vault's sequence, which invalidates all pre-authorized hashes pointing at a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Payments for AI agents that a human approves. Spending mandates enforced on the Stellar ledger: 2-of-2 vault, pre-authorised transaction envelopes, HTTP 402, USDC.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,7 +33,7 @@
33
33
  "node": ">=20"
34
34
  },
35
35
  "scripts": {
36
- "build": "tsc -p tsconfig.build.json",
36
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js dist/mcp.js",
37
37
  "test:dist": "npm run build && tsx stellar/dist-check.ts",
38
38
  "demo": "node sdk/demo.ts",
39
39
  "demo:stellar": "tsx stellar/demo.ts",
@@ -52,6 +52,8 @@
52
52
  "test:seed": "DEVELOPER_DIR=/Library/Developer/CommandLineTools /usr/bin/swiftc -target arm64-apple-macosx14.0 ios/PaydluxApprover/Seed.swift ios/PaydluxApprover/Hex.swift ios/Checks/main.swift -o \"$TMPDIR/seedcheck\" && \"$TMPDIR/seedcheck\"",
53
53
  "typecheck": "tsc --noEmit",
54
54
  "app": "tsx stellar/cli.ts",
55
+ "mcp": "tsx stellar/mcp.ts",
56
+ "test:mcp": "tsx stellar/mcp-check.ts",
55
57
  "app:mainnet": "tsx stellar/cli.ts setup --mainnet --file paydlux-mainnet.json --token XLM --max 2 --daily 20",
56
58
  "fund:check": "tsx stellar/check-address.ts GARPB3CO3JMMED3ETFRSSZXCH2X7UTXW4FNUP3Z7HDMBKAC2H355W4SA",
57
59
  "test:showcase": "tsx stellar/showcase-check.ts",
@@ -61,7 +63,13 @@
61
63
  "test:donate": "tsx stellar/donate-check.ts",
62
64
  "donate:live": "tsx stellar/donate-live.ts",
63
65
  "app:mainnet:sell": "tsx stellar/cli.ts sell --file paydlux-mainnet.json --split --tls --price 1 --route /market/data --max 2 --daily 20",
66
+ "app:mainnet:fund": "bash tools/fund-demo-wallet.sh",
67
+ "loop:check": "tsx stellar/mainnet-loop-check.ts",
64
68
  "app:testnet:sell": "tsx stellar/cli.ts sell --split --tls --showcase --price 1 --route /market/data --max 10 --daily 50",
69
+ "app:demo": "tsx stellar/cli.ts demo",
70
+ "app:demo:build": "bash tools/make-demo-app.sh",
71
+ "app:mainnet:build": "bash tools/make-demo-app.sh --mainnet",
72
+ "tunnel:service": "bash tools/install-tunnel-service.sh",
65
73
  "app:donate": "tsx stellar/cli.ts donate --file paydlux-mainnet.json --tls --price 1",
66
74
  "site:qr": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg",
67
75
  "address:check": "tsx stellar/check-address.ts",
@@ -93,18 +101,23 @@
93
101
  "access": "public"
94
102
  },
95
103
  "dependencies": {
104
+ "@modelcontextprotocol/sdk": "^1.32.2",
96
105
  "@stellar/stellar-sdk": "^17.4.0",
97
- "qrcode-generator": "^2.0.4"
106
+ "qrcode-generator": "^2.0.4",
107
+ "zod": "^4.6.7"
98
108
  },
99
109
  "devDependencies": {
100
110
  "@types/node": "^26.6.6",
101
111
  "jsqr": "^1.4.0",
112
+ "qrcode": "^1.5.4",
102
113
  "tsx": "^4.23.15",
103
114
  "typescript": "^7.0.2",
104
115
  "viem": "^2.57.13",
116
+ "wrangler": "^4.158.0",
105
117
  "xrpl": "^5.3.0"
106
118
  },
107
119
  "bin": {
108
- "paydlux": "dist/cli.js"
120
+ "paydlux": "dist/cli.js",
121
+ "paydlux-mcp": "dist/mcp.js"
109
122
  }
110
123
  }