@paydlux/agent 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -24,7 +24,9 @@ import { StellarVault, USDC_ISSUER, passphrase } from "./vault.js";
24
24
  import { Gateway } from "./gateway.js";
25
25
  import { newPortalKey } from "./desk.js";
26
26
  import { PolicyEngine } from "./policy.js";
27
- import { PUBLIC_HOST, runTunnel, tunnelLive, tunnelProblem } from "./tunnel.js";
27
+ import { PUBLIC_HOST, TUNNEL_NAME, runTunnel, tunnelLive, tunnelProblem, tunnelRunning, tunnelServes } from "./tunnel";
28
+ import { DEMO_HOST, DEMO_ROUTE, startDemo } from "./showcase";
29
+ import { DONATE_HOST, DONATE_PORT, startDonate } from "./donate";
28
30
  export const VAULT_FILE_DEFAULT = "paydlux-vault.json";
29
31
  const arg = (name) => {
30
32
  const i = process.argv.indexOf(`--${name}`);
@@ -100,6 +102,9 @@ async function trusts(vault, address) {
100
102
  const acc = await vault.horizon.loadAccount(address).catch(() => null);
101
103
  if (!acc)
102
104
  return false;
105
+ // Every account can already hold XLM; there is no trustline to be missing.
106
+ if (vault.isNative)
107
+ return true;
103
108
  const code = vault.token.getCode(), issuer = vault.token.getIssuer();
104
109
  return acc.balances.some((b) => b.asset_type !== "native"
105
110
  && b.asset_code === code && b.asset_issuer === issuer);
@@ -300,7 +305,15 @@ async function sell() {
300
305
  portalKey,
301
306
  publicHost,
302
307
  agentKeys: [pub(keys.agent)],
303
- routes: [{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600) }],
308
+ routes: [
309
+ { uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600) },
310
+ // --showcase adds a second priced route for the public page. Same price,
311
+ // same mandate, same desk: the showcase gets no special treatment at the
312
+ // spending door, it only gets its own hostname.
313
+ ...(flag("showcase")
314
+ ? [{ uri: DEMO_ROUTE, price, mode: mode, holdSeconds: Number(arg("hold") ?? 3600) }]
315
+ : []),
316
+ ],
304
317
  // The banner prints these numbers. This line is what makes them true.
305
318
  policy,
306
319
  });
@@ -309,13 +322,77 @@ async function sell() {
309
322
  const problem = tunnelProblem();
310
323
  if (problem)
311
324
  bad("--tls cannot run here: " + problem);
312
- tunnel = runTunnel();
325
+ // A second `sell` on this machine (the free testnet showcase and the real
326
+ // mainnet donation door run apart on purpose) must not spawn a second
327
+ // cloudflared for the same tunnel. Reuse the one already running.
328
+ if (tunnelRunning())
329
+ log("the named tunnel is already running on this machine — reusing it");
330
+ else
331
+ tunnel = runTunnel();
313
332
  }
314
333
  await gw.listen(gwPort, (u) => JSON.stringify({
315
334
  served: u, at: new Date().toISOString(),
316
335
  note: "paid through Paydlux; the payment is on the Stellar ledger",
317
336
  }));
318
337
  await gw.desk.listen(deskPort);
338
+ // The public showcase: a page where a stranger drives the real 402 loop.
339
+ // It is a second listener in front of the SAME gateway, so the mandate, the
340
+ // desk and the daily budget are the ones already under test. It is refused
341
+ // without --tls because a payment request over plain HTTP is a request
342
+ // somebody else can replay.
343
+ if (flag("showcase")) {
344
+ if (!tls) {
345
+ bad("--showcase needs --tls. The page hands out agent identities to strangers; "
346
+ + "doing that over plain HTTP means anyone on the path can collect them.");
347
+ }
348
+ const demoPort = Number(arg("demo-port") ?? 4023);
349
+ const who = await portHolder(demoPort);
350
+ if (who)
351
+ bad(`port ${demoPort} (showcase) is already in use by ${who}.`);
352
+ await startDemo({
353
+ port: demoPort, price, network, vault: vault.address, token: data.token.code,
354
+ explorer: data.explorer ?? "", gateway: gw,
355
+ onKeys: (pubs) => { gw.agentKeys = [pub(keys.agent), ...pubs]; },
356
+ });
357
+ log(`showcase is listening on 127.0.0.1:${demoPort} for https://${DEMO_HOST}`);
358
+ if (!tunnelServes(DEMO_HOST)) {
359
+ log(` the tunnel does not route ${DEMO_HOST} yet. Add to ~/.cloudflared/config.yml:`);
360
+ log(` - hostname: ${DEMO_HOST}`);
361
+ log(` service: http://127.0.0.1:${demoPort}`);
362
+ log(` then: cloudflared tunnel route dns ${TUNNEL_NAME} ${DEMO_HOST}`);
363
+ }
364
+ }
365
+ // The donation door: a stranger's agent pays US from THEIR vault. This is
366
+ // the seller door (stellar/receive.ts) with a page a human can read. It is a
367
+ // separate listener on a separate hostname because it has a different trust
368
+ // profile: it holds no key, signs nothing, and can only ever hand out content
369
+ // the ledger confirms we were paid for.
370
+ if (flag("donate")) {
371
+ if (!tls)
372
+ bad("--donate needs --tls. The order and its memo travel to a stranger's agent; over plain HTTP someone on the path can take them and pay for your order instead of theirs.");
373
+ const dPort = Number(arg("donate-port") ?? DONATE_PORT);
374
+ const who = await portHolder(dPort);
375
+ if (who)
376
+ bad(`port ${dPort} (donations) is already in use by ${who}.`);
377
+ const door = startDonate({
378
+ port: dPort, hostname: DONATE_HOST, payee, network,
379
+ price: arg("donate-price") ?? price,
380
+ asset: vault.isNative
381
+ ? { code: vault.token.getCode() }
382
+ : { code: vault.token.getCode(), issuer: vault.token.getIssuer() },
383
+ vault: vault.address,
384
+ });
385
+ // startReceive builds the routes and hands back listen(); without this call
386
+ // the door answers nothing and the tunnel returns 502 with no local error.
387
+ await door.listen(dPort);
388
+ log(`donations are listening on 127.0.0.1:${dPort} for https://${DONATE_HOST}`);
389
+ if (!tunnelServes(DONATE_HOST)) {
390
+ log(` the tunnel does not route ${DONATE_HOST} yet. Add to ~/.cloudflared/config.yml:`);
391
+ log(` - hostname: ${DONATE_HOST}`);
392
+ log(` service: http://127.0.0.1:${dPort}`);
393
+ log(` then: cloudflared tunnel route dns ${TUNNEL_NAME} ${DONATE_HOST}`);
394
+ }
395
+ }
319
396
  const deskUrl = tls ? `https://${PUBLIC_HOST}` : `http://127.0.0.1:${deskPort}`;
320
397
  if (tls) {
321
398
  const live = await tunnelLive();
@@ -331,6 +408,8 @@ async function sell() {
331
408
  approvals ${deskUrl}/?k=${portalKey}
332
409
  phone ${deskUrl}/phone/?k=${portalKey}
333
410
 
411
+ ${flag("donate") ? ` donations https://${DONATE_HOST} (they pay YOU from THEIR vault)
412
+ ` : ""}
334
413
  paste into your bot:
335
414
  import { PaydluxAgent } from "@paydlux/agent";
336
415
  const agent = new PaydluxAgent({ agentKey: "${keys.agent}", network: "${network}" });
@@ -372,10 +451,18 @@ async function status() {
372
451
  const rent = await vaultRent(vault);
373
452
  const signers = await vault.signerSummary();
374
453
  const live = signers.signers.filter((s) => s.type === "preauth-mandate");
454
+ // An XLM vault has no issuer at all — the ledger's own currency is not
455
+ // issued by anyone. This line used to call .slice() on undefined and the
456
+ // whole status command died with "Cannot read properties of undefined",
457
+ // which made the one command that shows real balances useless precisely on
458
+ // the real-money (XLM) setup.
459
+ const tokenLabel = data.token.issuer
460
+ ? `${data.token.code} (${String(data.token.issuer).slice(0, 8)}…)`
461
+ : `${data.token.code} (the ledger's own currency — no issuer)`;
375
462
  console.log(`
376
463
  network ${network}
377
464
  vault ${vault.address}
378
- token ${data.token.code} (${data.token.issuer.slice(0, 8)}…)
465
+ token ${tokenLabel}
379
466
  balance ${await vault.balance()} ${data.token.code}
380
467
  xlm rent ${rent.xlm} / ${rent.min} XLM ${rent.short ? "SHORT — payments will fail" : "ok"}
381
468
  payee ${data.payee?.address ?? "(not set)"}
@@ -408,36 +495,53 @@ async function setup() {
408
495
  const code = ((arg("token") ?? await ask("token for your payments", mainnet ? "USDC" : "AGPAY")) || "AGPAY").toUpperCase();
409
496
  const budget = Number(arg("max") ?? await ask("max per payment", mainnet ? "2" : "10"));
410
497
  const daily = Number(arg("daily") ?? await ask("daily budget", mainnet ? "10" : "50"));
411
- if (mainnet && code !== "USDC") {
412
- bad("on mainnet this first release only supports USDC. Custom tokens need an issuer key you would have to hold and protect — not a first-money decision.");
498
+ // XLM is a first-class settlement asset now, not a fallback. It is the
499
+ // cheapest way to take real money: no trustline for the buyer, no issuer to
500
+ // trust, and a visitor can fund a wallet from any exchange in one step.
501
+ const native = code === "XLM" || code === "NATIVE";
502
+ if (mainnet && !native && code !== "USDC") {
503
+ bad("on mainnet this first release only supports XLM and USDC. Custom tokens need an issuer key you would have to hold and protect — not a first-money decision.");
504
+ }
505
+ if (native && (budget < 1 || daily < 1)) {
506
+ bad("an XLM budget under 1 is not usable: Stellar amounts are 7 decimals but rent is paid in XLM, so a sub-1 daily budget would be eaten by the vault's own ledger rent.");
413
507
  }
414
508
  rl.close();
415
509
  console.log(`\nPaydlux setup — Stellar ${network}${mainnet ? " (REAL MONEY)" : ""}\n`);
416
510
  const principal = Keypair.random(), approver = Keypair.random(), agent = Keypair.random();
417
511
  const payee = Keypair.random();
418
- const issuer = code === "USDC" ? null : Keypair.random();
512
+ // XLM and USDC both have no issuer key for us to hold: XLM is the ledger's
513
+ // own currency and Circle mints USDC. Only a custom token needs one.
514
+ const issuer = native || code === "USDC" ? null : Keypair.random();
419
515
  // The account that pays the XLM for creating everything. On testnet that is
420
516
  // friendbot. On mainnet it has to be a wallet the human already controls.
421
517
  let funder;
422
518
  if (!mainnet) {
423
- if (!issuer)
424
- throw new Error("internal: a custom token needs an issuer key");
425
519
  console.log("funding on testnet (friendbot, no captcha)…");
426
- for (const k of [issuer, principal, approver, payee])
520
+ const toFund = [principal, approver, payee, ...(issuer ? [issuer] : [])];
521
+ for (const k of toFund)
427
522
  await StellarVault.fundTestnet(k);
428
- funder = issuer;
523
+ // With no issuer key, one of the throwaway funded accounts pays for the
524
+ // vault's creation. It is not a key the vault ever trusts.
525
+ funder = issuer ?? toFund[0];
429
526
  }
430
527
  else {
431
528
  const seed = arg("fund-seed") ?? process.env.PAYDLUX_FUND_SEED;
432
529
  if (!seed) {
530
+ const need = native ? 15 : 8;
433
531
  bad(`mainnet needs a wallet to pay the account-creation costs.
434
532
 
435
- paydlux setup --mainnet --fund-seed S…
533
+ paydlux setup --mainnet --token ${code} --fund-seed S…
436
534
 
437
- The wallet only needs a few XLM (about 8 XLM covers everything here, at
438
- ~0.17 USDT per XLM). It is used to CREATE two accounts; it cannot touch the
439
- vault afterwards, because the vault's own keys are made fresh right now and
440
- its master key is switched off in the same transaction that creates it.`);
535
+ The wallet needs ${need} XLM (at ~0.17 USDT per XLM that is a few dollars). It
536
+ is used to CREATE two accounts and nothing else: it cannot touch the vault
537
+ afterwards, because the vault's own keys are made fresh right now and its
538
+ master key is switched off in the same transaction that creates it.
539
+
540
+ If this wallet is brand new it does not exist on the ledger yet, and a plain
541
+ payment to it fails with op_no_account. It has to receive its first XLM from
542
+ something that creates the account for you: an exchange withdrawal does this
543
+ automatically, or any wallet with an "activate"/"fund new address" flow.
544
+ 1 XLM activates it; you need ${need} here.`);
441
545
  }
442
546
  funder = Keypair.fromSecret(seed);
443
547
  const h = new (await import("@stellar/stellar-sdk")).Horizon.Server("https://horizon.stellar.org");
@@ -445,8 +549,16 @@ async function setup() {
445
549
  if (!acc)
446
550
  bad(`the funding wallet ${funder.publicKey()} does not exist on mainnet. Send it some XLM first.`);
447
551
  const xlm = acc.balances.find((b) => b.asset_type === "native")?.balance ?? "0";
448
- if (Number(xlm) < 8)
449
- bad(`the funding wallet has ${xlm} XLM. Creating a vault + a payee account needs about 8 XLM.`);
552
+ // The vault's starting balance is locked partly as ledger reserve, and the
553
+ // payee account costs 2 more. An XLM vault needs a working balance on top
554
+ // of its reserve because it SPENDS XLM, so it costs more to open than a
555
+ // token vault. Say the real number instead of a guess.
556
+ const need = native ? 15 : 8;
557
+ if (Number(xlm) < need)
558
+ bad(`the funding wallet ${funder.publicKey()} has ${xlm} XLM and needs at least ${need}.
559
+ an XLM vault locks 2.5 XLM of reserve but also has to hold spendable XLM, so it
560
+ opens with 12; the payee account costs 2 more; fees are under 0.01.
561
+ Send more XLM to ${funder.publicKey()} and re-run. Nothing was created.`);
450
562
  console.log(`funding wallet ${funder.publicKey()} — ${xlm} XLM`);
451
563
  }
452
564
  console.log("creating the 2-of-2 vault…");
@@ -455,10 +567,14 @@ async function setup() {
455
567
  issuer: funder,
456
568
  principal, approver, agent,
457
569
  tokenCode: code,
458
- tokenIssuer: code === "USDC" ? USDC_ISSUER[network] : undefined,
570
+ // XLM has no issuer at all; USDC's issuer is Circle's account, which we
571
+ // hold no key for. Only a custom token gets the funder as issuer.
572
+ tokenIssuer: native ? undefined : (code === "USDC" ? USDC_ISSUER[network] : undefined),
459
573
  // mainnet: 2 base reserves + 3 signers + 1 trustline = 3 XLM locked, plus
460
574
  // room for one live mandate (0.5 XLM) and fees. 5 leaves a margin.
461
- startingBalance: mainnet ? "5" : undefined,
575
+ // An XLM vault skips the trustline, so it needs 0.5 XLM less — but the
576
+ // vault also *spends* XLM, so it needs a working balance, not just rent.
577
+ startingBalance: mainnet ? (native ? "12" : "5") : undefined,
462
578
  });
463
579
  console.log("vault created. master key is off: it can never act alone again.");
464
580
  // A Stellar account must exist before it can be paid, and a payment cannot
@@ -467,7 +583,7 @@ async function setup() {
467
583
  // with OP_NO_TRUST. This is the single most common reason a first real
468
584
  // payment is rejected, so it happens here, once, at setup.
469
585
  if (mainnet) {
470
- console.log("creating the payee account (2 XLM) and its USDC trustline…");
586
+ console.log(`creating the payee account (2 XLM)${native ? "" : " and its " + code + " trustline"}…`);
471
587
  const { Horizon, TransactionBuilder, Operation, Account, Networks } = await import("@stellar/stellar-sdk");
472
588
  const h = new Horizon.Server("https://horizon.stellar.org");
473
589
  const fa = await h.loadAccount(funder.publicKey());
@@ -478,14 +594,21 @@ async function setup() {
478
594
  await h.submitTransaction(t1);
479
595
  }
480
596
  await vault.trustToken(payee.publicKey(), "100000", payee);
481
- console.log("payee account ready and trusted for " + code + ".");
597
+ console.log(native
598
+ ? "payee account ready. XLM needs no trustline — it can already be paid."
599
+ : "payee account ready and trusted for " + code + ".");
600
+ // A custom token has to be minted into the vault before it can pay anything.
601
+ // XLM and USDC arrive from outside, so there is nothing to mint here.
482
602
  if (issuer)
483
603
  await vault.deposit(String(daily), issuer);
484
604
  const data = {
485
605
  createdAt: new Date().toISOString(),
486
606
  network,
487
607
  vault: vault.address,
488
- token: { code, issuer: code === "USDC" ? USDC_ISSUER[network] : issuer.publicKey() },
608
+ // XLM is stored with no issuer on purpose. A recorded issuer next to the
609
+ // name "XLM" would describe a fake token that anyone could issue, and
610
+ // parseToken() refuses that combination outright.
611
+ token: { code, ...(native ? {} : { issuer: code === "USDC" ? USDC_ISSUER[network] : issuer.publicKey() }) },
489
612
  policy: { maxPerPayment: budget, dailyBudget: daily, defaultPrice: mainnet ? "1" : "2" },
490
613
  payee: { address: payee.publicKey(), seed: payee.secret() },
491
614
  keys: {
@@ -630,6 +753,121 @@ async function withdraw() {
630
753
  console.log(`done. tx ${res?.hash}`);
631
754
  console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
632
755
  }
756
+ // ----------------------------------------------------------------------- usdc
757
+ /**
758
+ * Let the vault and its payee hold real Circle USDC.
759
+ *
760
+ * Circle mints USDC, so there is no issuer key for us to hold — an account
761
+ * only has to say it accepts USDC (a trustline) before the ledger will let it
762
+ * receive any. Without this, a payment of real USDC to the vault fails with
763
+ * OP_NO_TRUST and the money bounces back to the sender.
764
+ *
765
+ * Two trustlines are needed, not one: the vault, and the payee the 402 terms
766
+ * actually name as the destination. Adding only the vault's is the half-fix
767
+ * that looks set up and still bounces.
768
+ *
769
+ * Each trustline also raises the account's XLM rent by 0.5 XLM. The payee we
770
+ * create is funded with exactly one account entry (2 XLM), so a trustline on
771
+ * it fails OP_INSUFFICIENT_BALANCE unless it is topped up first. That is why
772
+ * this command pays the rent before it asks for the trustline.
773
+ *
774
+ * This does NOT change what the door charges. The live doors settle in XLM and
775
+ * the printed price stays XLM until someone deliberately switches the token;
776
+ * a trustline only makes USDC *possible*, it never silently reprices a live
777
+ * payment page.
778
+ */
779
+ async function usdc() {
780
+ const { data, path } = loadVault();
781
+ const network = (data.network ?? "testnet");
782
+ const keys = data.keys ?? {};
783
+ if (!keys.principal || !keys.approver) {
784
+ bad(`adding a trustline needs both human keys and this machine holds `
785
+ + `${keys.principal ? "only the principal" : "neither"}.\n`
786
+ + " run this on the machine with both, or approve from the phone.");
787
+ }
788
+ if (!data.payee?.seed) {
789
+ bad(`the payee ${data.payee?.address ?? "(none)"} is not ours to sign for, so we cannot add its\n`
790
+ + " trustline. Ask whoever owns that account to add a USDC trustline for:\n\n"
791
+ + ` ${USDC_ISSUER[network]}\n\n`
792
+ + " or drop --payee and let Paydlux manage a payee account it can sign for.");
793
+ }
794
+ const principal = Keypair.fromSecret(keys.principal);
795
+ const approver = Keypair.fromSecret(keys.approver);
796
+ const payeeKp = Keypair.fromSecret(data.payee.seed);
797
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: principal.publicKey(), approver: approver.publicKey(), agent: pub(keys.agent) }, network);
798
+ const has = async (address) => {
799
+ const acc = await vault.horizon.loadAccount(address).catch(() => null);
800
+ if (!acc)
801
+ return null;
802
+ const line = acc.balances.find((b) => b.asset_type !== "native"
803
+ && b.asset_code === "USDC" && b.asset_issuer === USDC_ISSUER[network]);
804
+ // Keep the two balances separate. Reading the native balance and labelling
805
+ // it "USDC" is how a setup check convinces you there is money in the wrong
806
+ // asset: XLM and USDC are not interchangeable and the ledger keeps them apart.
807
+ return {
808
+ xlm: acc.balances.find((b) => b.asset_type === "native")?.balance ?? "0",
809
+ usdc: line?.balance ?? "0.0000000",
810
+ line: !!line,
811
+ };
812
+ };
813
+ log(`USDC on ${network === "public" ? "mainnet" : network}: issuer ${USDC_ISSUER[network]}`);
814
+ // 1. the payee needs XLM rent for its new subentry before it can accept USDC.
815
+ const payeeNow = await has(data.payee.address);
816
+ if (!payeeNow)
817
+ bad(`${data.payee.address} does not exist on ${network}.`);
818
+ if (!payeeNow.line) {
819
+ const rent = await vaultRent(vault);
820
+ const topup = "0.6";
821
+ if (Number(rent.xlm) - Number(topup) < Number(rent.min) + 0.5) {
822
+ bad(`the vault cannot spare the payee's 0.5 XLM rent without going short itself\n`
823
+ + ` (vault has ${rent.xlm} XLM, needs ${rent.min} to stay open). Fund it first.`);
824
+ }
825
+ log(`paying the payee ${topup} XLM so it can afford the trustline rent…`);
826
+ await vault.release(data.payee.address, topup, principal, approver, "paydlux usdc rent");
827
+ }
828
+ // 2. both trustlines. useUsdc() first so trustToken() signs for the right asset.
829
+ vault.useUsdc();
830
+ const vaultNow = await has(vault.address);
831
+ if (vaultNow?.line)
832
+ log("the vault already holds a USDC trustline");
833
+ else {
834
+ log(`adding the vault's USDC trustline…`);
835
+ await vault.trustUsdc(principal, approver);
836
+ }
837
+ if (payeeNow.line)
838
+ log("the payee already holds a USDC trustline");
839
+ else {
840
+ log(`adding the payee's USDC trustline…`);
841
+ await vault.trustToken(data.payee.address, "1000000", payeeKp);
842
+ }
843
+ // 3. verify by re-reading the ledger. A trustline the ledger does not show is
844
+ // not a trustline.
845
+ const v2 = await has(vault.address);
846
+ const p2 = await has(data.payee.address);
847
+ console.log(`
848
+ vault ${vault.address}
849
+ USDC ${v2?.line ? "accepted" : "NOT ACCEPTED"} holds ${v2?.usdc} USDC, ${v2?.xlm} XLM
850
+ payee ${data.payee.address}
851
+ USDC ${p2?.line ? "accepted" : "NOT ACCEPTED"} holds ${p2?.usdc} USDC, ${p2?.xlm} XLM
852
+ `);
853
+ if (!v2?.line || !p2?.line)
854
+ bad("the ledger does not show both trustlines. nothing was set up.");
855
+ console.log(`both accounts can now hold real Circle USDC.
856
+
857
+ what that does NOT change: the doors still charge what their config says
858
+ (${data.token.code}), and the printed price on the page stays in that asset.
859
+ Switching the price itself is a deliberate step, because a live page that
860
+ promises 1 XLM and asks for 1 USDC is a page that takes the wrong money.
861
+
862
+ to put real USDC in, send it from an exchange to:
863
+
864
+ ${vault.address}
865
+
866
+ ${network === "public"
867
+ ? `on MAINNET, and pick the STELLAR network at the exchange — not Tron, not\nEthereum, not Solana. A withdrawal on the wrong network is not recoverable\nby us. No memo or destination tag is needed: this is your own account, not an\nexchange deposit address.`
868
+ : "on testnet."}
869
+ `);
870
+ }
633
871
  // ------------------------------------------------------------------------- main
634
872
  const HELP = `paydlux — get paid by AI agents, with a human in the loop.
635
873
 
@@ -652,6 +890,122 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
652
890
 
653
891
  one file holds it all: ./paydlux-vault.json (0600, never commit it)
654
892
  `;
893
+ /**
894
+ * `paydlux donate` — the public donation door, on its own.
895
+ *
896
+ * This is deliberately NOT `sell --donate`. Three reasons:
897
+ *
898
+ * 1. It reads no private key. The door's whole security property is that it
899
+ * cannot sign anything: it verifies an incoming payment against the public
900
+ * ledger and serves content only if the ledger says we were paid. Loading the
901
+ * vault file's seeds into this process would throw that property away, so
902
+ * only the public address, network and token code are read here.
903
+ * 2. It can run on the real network while the free showcase runs on testnet.
904
+ * The demo spends the seller's money and must stay play-money; the donation
905
+ * door has to take real XLM. They are separate processes on separate ports
906
+ * behind the same tunnel.
907
+ * 3. It has no gateway, no desk and no mandate, because nothing here spends the
908
+ * seller's money. Fewer moving parts means less that can leak.
909
+ */
910
+ async function donate() {
911
+ const path = arg("file") ?? VAULT_FILE_DEFAULT;
912
+ if (!existsSync(path))
913
+ bad(`no vault file at ${path}. Run: paydlux setup`);
914
+ const data = JSON.parse(readFileSync(path, "utf8"));
915
+ const network = data.network === "public" ? "public" : "testnet";
916
+ // Public fields only. If this file ever gains a field this code needs a seed
917
+ // for, that is a bug to fix here, not a reason to load the seeds.
918
+ const payee = typeof data.payee === "string"
919
+ ? data.payee
920
+ : data.payee?.address;
921
+ if (!payee)
922
+ bad(`the vault file has no payee address. Re-run: paydlux setup`);
923
+ // `vault` in the file is the account's public address (a string). Accept an
924
+ // object form too, but never read a seed from either.
925
+ const vaultAddress = typeof data.vault === "string"
926
+ ? data.vault
927
+ : data.vault?.address ?? data.vault?.publicKey;
928
+ if (!/^G[A-Z2-7]{55}$/.test(vaultAddress ?? "")) {
929
+ bad(`the vault file's address is not a valid Stellar account: ${vaultAddress}`);
930
+ }
931
+ let assetCode = data.token?.code ?? "XLM";
932
+ let assetIssuer = data.token?.issuer;
933
+ // --usdc prices this door in real Circle USDC instead of whatever the vault
934
+ // file says. Two things have to be true before that is safe to serve, and
935
+ // both are checked against the ledger rather than assumed:
936
+ //
937
+ // 1. the vault must already hold a USDC trustline. Without it every payment
938
+ // bounces with OP_NO_TRUST, the donor's money comes back, and the page
939
+ // still says "not paid yet" forever — a door that looks broken and is.
940
+ // 2. the payee the 402 terms name must hold one too. Here the payee IS the
941
+ // vault, so one check covers both, but the check is written for the
942
+ // account the terms actually name so it stays true if that ever changes.
943
+ if (flag("usdc")) {
944
+ if (assetCode !== "USDC")
945
+ log(`pricing in USDC instead of the file's ${assetCode}`);
946
+ assetCode = "USDC";
947
+ assetIssuer = USDC_ISSUER[network];
948
+ const h = network === "public" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org";
949
+ const acc = await fetch(`${h}/accounts/${vaultAddress}`).then((r) => r.ok ? r.json() : null).catch(() => null);
950
+ const line = acc?.balances?.find((b) => b.asset_type !== "native"
951
+ && b.asset_code === "USDC" && b.asset_issuer === assetIssuer);
952
+ if (!line) {
953
+ bad(`${vaultAddress} has no USDC trustline on ${network}, so a USDC payment would be\n`
954
+ + " rejected by the ledger (OP_NO_TRUST) and the donor's money would come back.\n\n"
955
+ + " add it first:\n\n"
956
+ + ` paydlux usdc --file ${path}\n`);
957
+ }
958
+ log(`USDC trustline confirmed on the ledger. Circle is the issuer: ${assetIssuer}`);
959
+ }
960
+ const price = arg("price") ?? data.policy?.defaultPrice ?? "1";
961
+ if (!(Number(price) > 0))
962
+ bad("--price must be a positive number");
963
+ const tls = flag("tls") || !process.env.PAYDLUX_NO_TLS;
964
+ if (tls) {
965
+ const problem = tunnelProblem();
966
+ if (problem)
967
+ bad("--tls cannot run here: " + problem);
968
+ if (!tunnelRunning())
969
+ runTunnel();
970
+ }
971
+ else {
972
+ log("running without TLS. The public door needs --tls: an order and its memo over plain HTTP can be taken by anyone on the path, who then pays for YOUR order with their vault and collects the goods.");
973
+ }
974
+ const dPort = Number(arg("port") ?? DONATE_PORT);
975
+ const who = await portHolder(dPort);
976
+ if (who) {
977
+ bad(`port ${dPort} (donations) is already in use by ${who}.\n`
978
+ + ` a donation door is already running. Stop it first, or this one answers nothing\n`
979
+ + ` and https://${DONATE_HOST} returns 502 with no error in this window.`);
980
+ }
981
+ // The vault address IS the payee here. Two reasons, and both matter:
982
+ //
983
+ // 1. Money lands straight in the 2-of-2. The gateway's payee account is a
984
+ // plain single-key account whose seed sits in a file on this machine, and
985
+ // it exists so the gateway can pay OUT without the vault signing every
986
+ // time. A donation has no reason to pass through it, so it does not.
987
+ // 2. One address on the page. The QR, the printed address and the 402 terms
988
+ // all name the same account, so a human scanning the code and an agent
989
+ // following the 402 loop cannot end up paying into different places.
990
+ const door = startDonate({
991
+ port: dPort, hostname: DONATE_HOST, payee: vaultAddress, network, price,
992
+ asset: assetIssuer ? { code: assetCode, issuer: assetIssuer } : { code: assetCode },
993
+ vault: vaultAddress,
994
+ });
995
+ await door.listen(dPort);
996
+ const live = tls ? await tunnelLive(DONATE_HOST, 12) : false;
997
+ console.log(`
998
+ donation door is ${live ? `LIVE at https://${DONATE_HOST}` : `listening on 127.0.0.1:${dPort}`}
999
+ network ${network === "public" ? "MAINNET — real money" : "testnet — play money"}
1000
+ price ${price} ${assetCode}
1001
+ paid into ${vaultAddress} (the 2-of-2 vault itself — watch every payment land here)
1002
+
1003
+ this process holds no signing key. It can only serve what the ledger confirms
1004
+ was paid for, and it cannot spend anything of yours.
1005
+ `);
1006
+ if (tls && !live)
1007
+ log(`https://${DONATE_HOST} is not answering yet. ${tunnelServes(DONATE_HOST) ? "Give the tunnel a few seconds." : `The tunnel does not route ${DONATE_HOST} — add it to ~/.cloudflared/config.yml pointing at 127.0.0.1:${dPort}.`}`);
1008
+ }
655
1009
  export async function main() {
656
1010
  const cmd = process.argv[2];
657
1011
  if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
@@ -662,12 +1016,16 @@ export async function main() {
662
1016
  return setup();
663
1017
  if (cmd === "sell")
664
1018
  return sell();
1019
+ if (cmd === "donate")
1020
+ return donate();
665
1021
  if (cmd === "status")
666
1022
  return status();
667
1023
  if (cmd === "approver")
668
1024
  return approver();
669
1025
  if (cmd === "withdraw")
670
1026
  return withdraw();
1027
+ if (cmd === "usdc")
1028
+ return usdc();
671
1029
  bad(`unknown command "${cmd}".\n\n${HELP}`);
672
1030
  }
673
1031
  // Run only when this file is the entry point, so importing it in a test is safe.
package/dist/desk.js CHANGED
@@ -323,6 +323,10 @@ export class ApprovalDesk {
323
323
  amount: rec.price,
324
324
  tokenCode: this.cfg.vault.token.getCode(),
325
325
  tokenIssuer: this.cfg.vault.token.getIssuer() ?? "",
326
+ // Stated, not inferred from the name: a token someone issued and
327
+ // called XLM must not be turned into native XLM on the way to the
328
+ // phone, because that would change what the human is approving.
329
+ native: this.cfg.vault.isNative,
326
330
  network: this.cfg.vault.network,
327
331
  horizon: this.cfg.vault.horizon,
328
332
  id: rec.id,
@@ -0,0 +1,48 @@
1
+ import type { Network } from "./receive";
2
+ export declare const DONATE_HOST = "donate.paydlux.com";
3
+ export declare const DONATE_PORT = 4024;
4
+ export type DonatePageConfig = {
5
+ price: string;
6
+ asset: string;
7
+ network: Network;
8
+ payee: string;
9
+ vault: string;
10
+ explorer: string;
11
+ };
12
+ /**
13
+ * The page. No inline script and nothing visitor-controlled is interpolated, so
14
+ * the strict CSP is real rather than decorative — the same rule the showcase
15
+ * follows, because this port is public.
16
+ */
17
+ export declare function donatePage(cfg: DonatePageConfig): string;
18
+ /**
19
+ * The browser half. It does NOT sign anything — it cannot, and that is the
20
+ * design. It fetches an order, prints the exact command your agent runs, and
21
+ * then polls until the ledger says the money landed.
22
+ */
23
+ export declare function donateScript(): string;
24
+ /**
25
+ * Start the donation endpoint: the seller door plus a page a human can read.
26
+ */
27
+ export declare function startDonate(cfg: {
28
+ port: number;
29
+ hostname: string;
30
+ payee: string;
31
+ network: Network;
32
+ price: string;
33
+ asset: {
34
+ code: string;
35
+ issuer?: string;
36
+ };
37
+ vault: string;
38
+ explorer?: string;
39
+ /** Test seam: a stub ledger, so the routes can be driven without a network. */
40
+ fetchJson?: (url: string) => Promise<{
41
+ status: number;
42
+ body: any;
43
+ } | null>;
44
+ }): {
45
+ server: import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>;
46
+ book: import("./receive").OrderBook;
47
+ listen: (port: number) => Promise<void>;
48
+ };