@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 +55 -0
- package/dist/cli.js +282 -3
- package/dist/desk.d.ts +9 -0
- package/dist/desk.js +32 -2
- package/dist/mcp.d.ts +24 -0
- package/dist/mcp.js +387 -0
- package/dist/showcase.d.ts +14 -0
- package/dist/showcase.js +99 -4
- package/dist/vault.d.ts +13 -0
- package/dist/vault.js +22 -0
- package/package.json +17 -4
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/showcase.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
}
|