@paydlux/agent 0.1.22 → 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 +80 -0
- package/dist/cli.js +344 -16
- package/dist/desk.d.ts +51 -1
- package/dist/desk.js +240 -30
- package/dist/gateway.d.ts +6 -1
- package/dist/gateway.js +1 -0
- package/dist/mcp.d.ts +24 -0
- package/dist/mcp.js +387 -0
- package/dist/policy.d.ts +51 -1
- package/dist/policy.js +102 -3
- package/dist/portal.d.ts +17 -0
- package/dist/portal.js +97 -1
- 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 +19 -4
- package/portal-app/portal.css +22 -0
- package/portal-app/rules.js +62 -0
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
|
|
@@ -242,6 +297,31 @@ too, because a budget that silently reads as unspent is worse than no budget.
|
|
|
242
297
|
The banner shows what is left: `policy: max 10 per payment, 50 per day (27.50
|
|
243
298
|
left today)`.
|
|
244
299
|
|
|
300
|
+
### Spend it by category, and edit the rules in a browser
|
|
301
|
+
|
|
302
|
+
One vault, several pockets. Open **spending rules** on the approvals page (or
|
|
303
|
+
`/rules` on the desk) and set, per named category, its own per-payment ceiling and
|
|
304
|
+
daily budget — then say which seller wallet belongs to which category:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
data-api max 3 / payment 6 / day ← api.example.com wallet
|
|
308
|
+
coding max 1 / payment 2 / day ← assistant wallet
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
A category can only be **tighter** than the limits above it; it can never raise
|
|
312
|
+
them, because the base ceilings are checked first. That asymmetry is the security
|
|
313
|
+
property, and `npm run test:policy` proves it, along with the queue counting
|
|
314
|
+
inside a category and the fact that a form posted from another site cannot raise
|
|
315
|
+
your budget.
|
|
316
|
+
|
|
317
|
+
Edits are saved to the vault's config file, so they survive a restart. Once you
|
|
318
|
+
have edited them there, the saved numbers win over `--max`/`--daily` on the next
|
|
319
|
+
start, and the seller prints which it is using — a rule that quietly reverts by
|
|
320
|
+
morning is worse than no rule.
|
|
321
|
+
|
|
322
|
+
`stellar/policy.ts` is the engine; the page is a plain HTML form, so it works with
|
|
323
|
+
JavaScript off.
|
|
324
|
+
|
|
245
325
|
### What is enforced where (said honestly)
|
|
246
326
|
|
|
247
327
|
| enforced by Stellar | enforced by the desk (`policy.ts`) |
|
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}`);
|
|
@@ -221,7 +222,26 @@ async function sell() {
|
|
|
221
222
|
// desk is even built.
|
|
222
223
|
const maxPer = Number(arg("max") ?? data.policy?.maxPerPayment ?? 2);
|
|
223
224
|
const daily = Number(arg("daily") ?? data.policy?.dailyBudget ?? 10);
|
|
224
|
-
|
|
225
|
+
// Categories and the wallet map come from the config file only: they are set
|
|
226
|
+
// at the desk (/rules), not from the command line. A flag for them would be a
|
|
227
|
+
// second, easier way to say the same thing, and the page would drift from it.
|
|
228
|
+
//
|
|
229
|
+
// The same is true of the two ceilings once an owner edits them at the desk:
|
|
230
|
+
// the saved file wins over the flags, because the app scripts pass --max and
|
|
231
|
+
// --daily on every start and would otherwise silently undo whatever was set in
|
|
232
|
+
// the browser. The line printed below is what keeps that from being a
|
|
233
|
+
// surprise — a rule that quietly reverts is worse than no rule at all.
|
|
234
|
+
const saved = data.policy?.savedByOwner === true;
|
|
235
|
+
const maxPerFinal = saved ? Number(data.policy.maxPerPayment ?? maxPer) : maxPer;
|
|
236
|
+
const dailyFinal = saved ? Number(data.policy.dailyBudget ?? daily) : daily;
|
|
237
|
+
if (saved && (maxPerFinal !== maxPer || dailyFinal !== daily)) {
|
|
238
|
+
log(`using the rules you saved at the desk (max ${maxPerFinal}, daily ${dailyFinal}) instead of the command line (${maxPer}/${daily}).\n to change them: open the approvals link and press "spending rules".`);
|
|
239
|
+
}
|
|
240
|
+
const policy = new PolicyEngine({
|
|
241
|
+
maxPerPayment: maxPerFinal, dailyBudget: dailyFinal,
|
|
242
|
+
allowlist: data.policy?.allowlist, denylist: data.policy?.denylist,
|
|
243
|
+
categories: data.policy?.categories, payeeCategory: data.policy?.payeeCategory,
|
|
244
|
+
});
|
|
225
245
|
// A ceiling below the price blocks every payment, and the failure looks like
|
|
226
246
|
// a bug in the desk: the buyer waits, the human never sees an approvable
|
|
227
247
|
// item, and the log says "mandate refused". Say it now, before anything is
|
|
@@ -233,16 +253,16 @@ async function sell() {
|
|
|
233
253
|
// takes money IN from the buyer, so the ceiling does not apply to it — and
|
|
234
254
|
// applying it would refuse a perfectly good escrow price for no reason.
|
|
235
255
|
if (mode === "escrow") {
|
|
236
|
-
if (priceNum >
|
|
237
|
-
log(`note: --escrow price ${price} is above --max ${
|
|
256
|
+
if (priceNum > maxPerFinal) {
|
|
257
|
+
log(`note: --escrow price ${price} is above --max ${maxPerFinal}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
|
|
238
258
|
}
|
|
239
259
|
}
|
|
240
260
|
else {
|
|
241
|
-
if (priceNum >
|
|
242
|
-
bad(`--price ${price} is above --max ${
|
|
261
|
+
if (priceNum > maxPerFinal) {
|
|
262
|
+
bad(`--price ${price} is above --max ${maxPerFinal}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
|
|
243
263
|
}
|
|
244
|
-
if (priceNum >
|
|
245
|
-
bad(`--price ${price} is above --daily ${
|
|
264
|
+
if (priceNum > dailyFinal) {
|
|
265
|
+
bad(`--price ${price} is above --daily ${dailyFinal}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
268
|
const vault = await 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);
|
|
@@ -267,20 +287,31 @@ async function sell() {
|
|
|
267
287
|
+ `the daily budget of ${daily} cannot be enforced without it, so nothing was started. `
|
|
268
288
|
+ `Check the network and try again.`);
|
|
269
289
|
}
|
|
270
|
-
policy.seedFromLedger(alreadyPaid);
|
|
290
|
+
policy.seedFromLedger(alreadyPaid, policy.categoryOf(payee));
|
|
271
291
|
if (alreadyPaid > 0) {
|
|
272
|
-
log(`ledger says ${alreadyPaid.toFixed(2)} of the ${
|
|
273
|
-
if (alreadyPaid >=
|
|
274
|
-
bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${
|
|
292
|
+
log(`ledger says ${alreadyPaid.toFixed(2)} of the ${dailyFinal} daily budget is already spent today`);
|
|
293
|
+
if (alreadyPaid >= dailyFinal) {
|
|
294
|
+
bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${dailyFinal} allowed.\n `
|
|
275
295
|
+ `the mandate would refuse every payment. Raise --daily or wait for the UTC day to roll over.`);
|
|
276
296
|
}
|
|
277
297
|
}
|
|
298
|
+
// Say out loud what the rules are, including the categories, so the numbers
|
|
299
|
+
// an owner set in a browser are visible to whoever starts the seller. A rule
|
|
300
|
+
// nobody can see is a rule nobody trusts.
|
|
301
|
+
const catNames = Object.keys(policy.policy.categories ?? {});
|
|
302
|
+
if (catNames.length) {
|
|
303
|
+
const mapped = Object.entries(policy.policy.payeeCategory ?? {});
|
|
304
|
+
log(`spending categories: ${catNames.map((c) => {
|
|
305
|
+
const r = policy.policy.categories[c];
|
|
306
|
+
return `${c} (${r.maxPerPayment ?? policy.policy.maxPerPayment}/payment, ${r.dailyBudget ?? dailyFinal}/day)`;
|
|
307
|
+
}).join(", ")}${mapped.length ? ` · ${mapped.length} wallet(s) mapped` : ""}`);
|
|
308
|
+
}
|
|
278
309
|
if (network === "public") {
|
|
279
310
|
console.log(`
|
|
280
311
|
MAINNET — real money.
|
|
281
312
|
token ${data.token.code}
|
|
282
|
-
max per payment ${
|
|
283
|
-
daily budget ${
|
|
313
|
+
max per payment ${maxPerFinal}
|
|
314
|
+
daily budget ${dailyFinal}
|
|
284
315
|
vault ${vault.address}
|
|
285
316
|
balance ${await vault.balance()} ${data.token.code}
|
|
286
317
|
`);
|
|
@@ -337,6 +368,25 @@ async function sell() {
|
|
|
337
368
|
],
|
|
338
369
|
// The banner prints these numbers. This line is what makes them true.
|
|
339
370
|
policy,
|
|
371
|
+
// Rules edited at the desk must survive the next restart, or the mandate in
|
|
372
|
+
// the banner is a promise that lasts until ctrl-c. Written back to the same
|
|
373
|
+
// config file, with the same 0600 as everywhere else in this file, because
|
|
374
|
+
// it also holds key material.
|
|
375
|
+
onPolicyChange: (p) => {
|
|
376
|
+
data.policy = {
|
|
377
|
+
...(data.policy ?? {}),
|
|
378
|
+
// The marker that says "a human set these in a browser". Without it the
|
|
379
|
+
// app scripts' --max/--daily flags win on every restart and the edit is
|
|
380
|
+
// lost by morning.
|
|
381
|
+
savedByOwner: true,
|
|
382
|
+
maxPerPayment: p.maxPerPayment, dailyBudget: p.dailyBudget,
|
|
383
|
+
allowlist: p.allowlist ?? [], denylist: p.denylist ?? [],
|
|
384
|
+
categories: p.categories ?? {}, payeeCategory: p.payeeCategory ?? {},
|
|
385
|
+
};
|
|
386
|
+
writeFileSync(path, JSON.stringify(data, null, 2));
|
|
387
|
+
chmodSync(path, 0o600);
|
|
388
|
+
log(`rules saved to ${path}`);
|
|
389
|
+
},
|
|
340
390
|
});
|
|
341
391
|
let tunnel = null;
|
|
342
392
|
if (tls) {
|
|
@@ -372,7 +422,7 @@ async function sell() {
|
|
|
372
422
|
bad(`port ${demoPort} (showcase) is already in use by ${who}.`);
|
|
373
423
|
await startDemo({
|
|
374
424
|
port: demoPort, price, network, vault: vault.address, token: data.token.code,
|
|
375
|
-
explorer: data.explorer ?? "", gateway: gw,
|
|
425
|
+
explorer: data.explorer ?? "", gateway: gw, route: uri, payee,
|
|
376
426
|
onKeys: (pubs) => { gw.agentKeys = [pub(keys.agent), ...pubs]; },
|
|
377
427
|
});
|
|
378
428
|
log(`showcase is listening on 127.0.0.1:${demoPort} for https://${DEMO_HOST}`);
|
|
@@ -455,6 +505,257 @@ ${flag("donate") ? ` donations https://${DONATE_HOST} (they pay YOU from THE
|
|
|
455
505
|
process.on("SIGINT", bye);
|
|
456
506
|
process.on("SIGTERM", bye);
|
|
457
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
|
+
}
|
|
458
759
|
/** XLM the vault must hold to exist, versus what it actually has. */
|
|
459
760
|
async function vaultRent(vault) {
|
|
460
761
|
const acc = await vault.horizon.loadAccount(vault.address);
|
|
@@ -770,7 +1071,19 @@ async function withdraw() {
|
|
|
770
1071
|
bad("then run this again — approvals from the phone land automatically.");
|
|
771
1072
|
}
|
|
772
1073
|
console.log(`sending ${want} ${data.token.code} from the vault to ${to} …`);
|
|
773
|
-
|
|
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");
|
|
774
1087
|
console.log(`done. tx ${res?.hash}`);
|
|
775
1088
|
console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
|
|
776
1089
|
}
|
|
@@ -1027,6 +1340,12 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
1027
1340
|
in a claimable balance you cannot touch until --release seconds have
|
|
1028
1341
|
passed, and the buyer can take it back any time before then. You get paid
|
|
1029
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.
|
|
1030
1349
|
paydlux escrow [--release ORD | --refund ESCROW_ID]
|
|
1031
1350
|
list what is locked for your vault, release one order, or take a buyer's
|
|
1032
1351
|
money back. Release needs both human keys; on a --split vault it prints
|
|
@@ -1046,6 +1365,11 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
1046
1365
|
paydlux withdraw --to G… [--all | --amount N]
|
|
1047
1366
|
take your money back out of the vault. Needs both human keys; the agent
|
|
1048
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.
|
|
1049
1373
|
|
|
1050
1374
|
one file holds it all: ./paydlux-vault.json (0600, never commit it)
|
|
1051
1375
|
`;
|
|
@@ -1191,6 +1515,8 @@ export async function main() {
|
|
|
1191
1515
|
return setup();
|
|
1192
1516
|
if (cmd === "sell")
|
|
1193
1517
|
return sell();
|
|
1518
|
+
if (cmd === "demo")
|
|
1519
|
+
return demo();
|
|
1194
1520
|
if (cmd === "donate")
|
|
1195
1521
|
return donate();
|
|
1196
1522
|
if (cmd === "status")
|
|
@@ -1203,6 +1529,8 @@ export async function main() {
|
|
|
1203
1529
|
return usdc();
|
|
1204
1530
|
if (cmd === "escrow")
|
|
1205
1531
|
return escrow();
|
|
1532
|
+
if (cmd === "mcp")
|
|
1533
|
+
return startMcp();
|
|
1206
1534
|
bad(`unknown command "${cmd}".\n\n${HELP}`);
|
|
1207
1535
|
}
|
|
1208
1536
|
// Run only when this file is the entry point, so importing it in a test is safe.
|
package/dist/desk.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import type { StellarVault } from "./vault.ts";
|
|
3
|
-
import type { PolicyEngine } from "./policy.ts";
|
|
3
|
+
import type { PolicyEngine, Policy } from "./policy.ts";
|
|
4
4
|
import { type ApprovalChallenge } from "./phone.ts";
|
|
5
5
|
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
6
6
|
* random, and compared without leaking timing. */
|
|
@@ -96,10 +96,31 @@ export type DeskConfig = {
|
|
|
96
96
|
* ceiling can actually hold.
|
|
97
97
|
*/
|
|
98
98
|
policy?: PolicyEngine;
|
|
99
|
+
/**
|
|
100
|
+
* Called when the human edits the spending rules from the portal. The CLI
|
|
101
|
+
* wires this to the config file, so a rule set at the desk survives the next
|
|
102
|
+
* restart instead of resetting to the command-line defaults.
|
|
103
|
+
*
|
|
104
|
+
* A callback rather than a path: the desk must not know where secrets live.
|
|
105
|
+
* The config file also holds key material, so the writer stays with the code
|
|
106
|
+
* that already handles it (0600, same serialisation).
|
|
107
|
+
*/
|
|
108
|
+
onPolicyChange?: (policy: Policy) => void;
|
|
99
109
|
};
|
|
100
110
|
export declare class ApprovalDesk {
|
|
101
111
|
private records;
|
|
102
112
|
private seq;
|
|
113
|
+
/** when /rules last saved, so the page can say "saved" without a query flag */
|
|
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;
|
|
103
124
|
private server?;
|
|
104
125
|
cfg: DeskConfig;
|
|
105
126
|
constructor(cfg: DeskConfig);
|
|
@@ -168,6 +189,35 @@ export declare class ApprovalDesk {
|
|
|
168
189
|
private mandateBlock;
|
|
169
190
|
/** The mandate has been satisfied: count the money as spent. */
|
|
170
191
|
private recordSpend;
|
|
192
|
+
/** The rules as they stand, for the /rules page. */
|
|
193
|
+
policyView(): Policy;
|
|
194
|
+
/** What today's counters read, per category, for the /rules page. */
|
|
195
|
+
private spentView;
|
|
196
|
+
/**
|
|
197
|
+
* The cross-origin gate used by every route that changes money or the rules
|
|
198
|
+
* that gate it. A cross-origin fetch POST is a CORS *simple request*: no
|
|
199
|
+
* preflight, so the attacker's page never sees the reply but the action still
|
|
200
|
+
* happens. Browsers always send Origin on POST and Sec-Fetch-Site on every
|
|
201
|
+
* request, so checking both closes that path while leaving local automation
|
|
202
|
+
* (curl, tests, the phone app on its own origin) alone.
|
|
203
|
+
*
|
|
204
|
+
* Returns a reason string when the request must be refused, null when it may
|
|
205
|
+
* proceed.
|
|
206
|
+
*/
|
|
207
|
+
private crossOriginGate;
|
|
208
|
+
/**
|
|
209
|
+
* Apply an edit from the /rules form.
|
|
210
|
+
*
|
|
211
|
+
* The form carries the whole rule set, so this rebuilds rather than patches:
|
|
212
|
+
* what you see on the page is what gets saved, and a stale field cannot
|
|
213
|
+
* survive an edit. Names and numbers are validated inside PolicyEngine.update
|
|
214
|
+
* — this function only decides which fields the form is allowed to touch.
|
|
215
|
+
*
|
|
216
|
+
* Deliberately absent: maxMandateTtl and maxLiveMandates. Those are ledger
|
|
217
|
+
* constraints (sequence binding), not owner preferences, and a form field for
|
|
218
|
+
* them would be an invitation to configure a mandate the chain will refuse.
|
|
219
|
+
*/
|
|
220
|
+
private applyRules;
|
|
171
221
|
approve(id: string): Promise<DeskRecord>;
|
|
172
222
|
/** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
|
|
173
223
|
private requestPhoneSignature;
|