@paydlux/agent 0.1.17 → 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/README.md CHANGED
@@ -234,11 +234,19 @@ combination blocks every payment and looks like a bug in the desk.
234
234
  Verified on the published package: 6 x 4.50 against `--daily 20` -> four
235
235
  settled, two refused, vault balance moved exactly 18.00 and stopped.
236
236
 
237
+ **The budget survives a restart.** `paydlux sell` asks the ledger what the vault
238
+ already paid that payee today and starts from that number, so `ctrl-c` and
239
+ `npm run app` again does not hand the day back. If the day is already spent it
240
+ refuses to start and says so; if the ledger cannot be read it refuses to start
241
+ too, because a budget that silently reads as unspent is worse than no budget.
242
+ The banner shows what is left: `policy: max 10 per payment, 50 per day (27.50
243
+ left today)`.
244
+
237
245
  ### What is enforced where (said honestly)
238
246
 
239
247
  | enforced by Stellar | enforced by the desk (`policy.ts`) |
240
248
  |---|---|
241
- | payee, token, time window, one-shot execution, per-payment ceiling as a signer rule | per-payment ceiling and daily total at the approve button and again at submission, running daily total, seller allow/deny list |
249
+ | payee, token, time window, one-shot execution, per-payment ceiling as a signer rule | per-payment ceiling and daily total at the approve button and again at submission, running daily total **seeded from the ledger's own payment history**, seller allow/deny list |
242
250
 
243
251
  The vault is one payment at a time; counting payments is our job. A rule break
244
252
  means we never build the transaction, so nothing reaches the ledger.
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);
@@ -227,6 +232,29 @@ async function sell() {
227
232
  // payment with no valid payee is a payment that fails an hour later, after
228
233
  // the human already said yes.
229
234
  const payee = await ensurePayee(path, data, vault);
235
+ // The daily budget has to survive a restart. Counting only what this process
236
+ // has seen means `ctrl-c`, `npm run app` again, and the day's total is back to
237
+ // zero — so the mandate in the banner is a promise the code does not keep.
238
+ // Ask the ledger what the vault already paid this payee today and start from
239
+ // there. If the query fails, refuse to start rather than run with a budget
240
+ // that silently reads as unspent.
241
+ let alreadyPaid = 0;
242
+ try {
243
+ alreadyPaid = Number(await vault.paidToday(payee));
244
+ }
245
+ catch (e) {
246
+ bad(`cannot read today's spending from the ledger (${e?.message ?? e}).\n `
247
+ + `the daily budget of ${daily} cannot be enforced without it, so nothing was started. `
248
+ + `Check the network and try again.`);
249
+ }
250
+ policy.seedFromLedger(alreadyPaid);
251
+ if (alreadyPaid > 0) {
252
+ log(`ledger says ${alreadyPaid.toFixed(2)} of the ${daily} daily budget is already spent today`);
253
+ if (alreadyPaid >= daily) {
254
+ bad(`today's budget is already gone: ${alreadyPaid.toFixed(2)} paid, ${daily} allowed.\n `
255
+ + `the mandate would refuse every payment. Raise --daily or wait for the UTC day to roll over.`);
256
+ }
257
+ }
230
258
  if (network === "public") {
231
259
  console.log(`
232
260
  MAINNET — real money.
@@ -277,7 +305,15 @@ async function sell() {
277
305
  portalKey,
278
306
  publicHost,
279
307
  agentKeys: [pub(keys.agent)],
280
- 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
+ ],
281
317
  // The banner prints these numbers. This line is what makes them true.
282
318
  policy,
283
319
  });
@@ -286,13 +322,77 @@ async function sell() {
286
322
  const problem = tunnelProblem();
287
323
  if (problem)
288
324
  bad("--tls cannot run here: " + problem);
289
- 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();
290
332
  }
291
333
  await gw.listen(gwPort, (u) => JSON.stringify({
292
334
  served: u, at: new Date().toISOString(),
293
335
  note: "paid through Paydlux; the payment is on the Stellar ledger",
294
336
  }));
295
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
+ }
296
396
  const deskUrl = tls ? `https://${PUBLIC_HOST}` : `http://127.0.0.1:${deskPort}`;
297
397
  if (tls) {
298
398
  const live = await tunnelLive();
@@ -308,6 +408,8 @@ async function sell() {
308
408
  approvals ${deskUrl}/?k=${portalKey}
309
409
  phone ${deskUrl}/phone/?k=${portalKey}
310
410
 
411
+ ${flag("donate") ? ` donations https://${DONATE_HOST} (they pay YOU from THEIR vault)
412
+ ` : ""}
311
413
  paste into your bot:
312
414
  import { PaydluxAgent } from "@paydlux/agent";
313
415
  const agent = new PaydluxAgent({ agentKey: "${keys.agent}", network: "${network}" });
@@ -316,7 +418,7 @@ async function sell() {
316
418
  ${split
317
419
  ? "approvals need your phone. this machine holds one of the two keys."
318
420
  : "WARNING: this process holds BOTH human keys. Anyone with this machine can approve. Use --split for anything real."}
319
- policy: max ${maxPer} per payment, ${daily} per day
421
+ policy: max ${maxPer} per payment, ${daily} per day${alreadyPaid > 0 ? ` (${(daily - alreadyPaid).toFixed(2)} left today)` : ""}
320
422
  ctrl-c to stop
321
423
  `);
322
424
  // The desk is a queue, not a database. If it dies with a payment parked, the
@@ -349,10 +451,18 @@ async function status() {
349
451
  const rent = await vaultRent(vault);
350
452
  const signers = await vault.signerSummary();
351
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)`;
352
462
  console.log(`
353
463
  network ${network}
354
464
  vault ${vault.address}
355
- token ${data.token.code} (${data.token.issuer.slice(0, 8)}…)
465
+ token ${tokenLabel}
356
466
  balance ${await vault.balance()} ${data.token.code}
357
467
  xlm rent ${rent.xlm} / ${rent.min} XLM ${rent.short ? "SHORT — payments will fail" : "ok"}
358
468
  payee ${data.payee?.address ?? "(not set)"}
@@ -385,36 +495,53 @@ async function setup() {
385
495
  const code = ((arg("token") ?? await ask("token for your payments", mainnet ? "USDC" : "AGPAY")) || "AGPAY").toUpperCase();
386
496
  const budget = Number(arg("max") ?? await ask("max per payment", mainnet ? "2" : "10"));
387
497
  const daily = Number(arg("daily") ?? await ask("daily budget", mainnet ? "10" : "50"));
388
- if (mainnet && code !== "USDC") {
389
- 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.");
390
507
  }
391
508
  rl.close();
392
509
  console.log(`\nPaydlux setup — Stellar ${network}${mainnet ? " (REAL MONEY)" : ""}\n`);
393
510
  const principal = Keypair.random(), approver = Keypair.random(), agent = Keypair.random();
394
511
  const payee = Keypair.random();
395
- 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();
396
515
  // The account that pays the XLM for creating everything. On testnet that is
397
516
  // friendbot. On mainnet it has to be a wallet the human already controls.
398
517
  let funder;
399
518
  if (!mainnet) {
400
- if (!issuer)
401
- throw new Error("internal: a custom token needs an issuer key");
402
519
  console.log("funding on testnet (friendbot, no captcha)…");
403
- for (const k of [issuer, principal, approver, payee])
520
+ const toFund = [principal, approver, payee, ...(issuer ? [issuer] : [])];
521
+ for (const k of toFund)
404
522
  await StellarVault.fundTestnet(k);
405
- 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];
406
526
  }
407
527
  else {
408
528
  const seed = arg("fund-seed") ?? process.env.PAYDLUX_FUND_SEED;
409
529
  if (!seed) {
530
+ const need = native ? 15 : 8;
410
531
  bad(`mainnet needs a wallet to pay the account-creation costs.
411
532
 
412
- paydlux setup --mainnet --fund-seed S…
533
+ paydlux setup --mainnet --token ${code} --fund-seed S…
413
534
 
414
- The wallet only needs a few XLM (about 8 XLM covers everything here, at
415
- ~0.17 USDT per XLM). It is used to CREATE two accounts; it cannot touch the
416
- vault afterwards, because the vault's own keys are made fresh right now and
417
- 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.`);
418
545
  }
419
546
  funder = Keypair.fromSecret(seed);
420
547
  const h = new (await import("@stellar/stellar-sdk")).Horizon.Server("https://horizon.stellar.org");
@@ -422,8 +549,16 @@ async function setup() {
422
549
  if (!acc)
423
550
  bad(`the funding wallet ${funder.publicKey()} does not exist on mainnet. Send it some XLM first.`);
424
551
  const xlm = acc.balances.find((b) => b.asset_type === "native")?.balance ?? "0";
425
- if (Number(xlm) < 8)
426
- 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.`);
427
562
  console.log(`funding wallet ${funder.publicKey()} — ${xlm} XLM`);
428
563
  }
429
564
  console.log("creating the 2-of-2 vault…");
@@ -432,10 +567,14 @@ async function setup() {
432
567
  issuer: funder,
433
568
  principal, approver, agent,
434
569
  tokenCode: code,
435
- 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),
436
573
  // mainnet: 2 base reserves + 3 signers + 1 trustline = 3 XLM locked, plus
437
574
  // room for one live mandate (0.5 XLM) and fees. 5 leaves a margin.
438
- 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,
439
578
  });
440
579
  console.log("vault created. master key is off: it can never act alone again.");
441
580
  // A Stellar account must exist before it can be paid, and a payment cannot
@@ -444,7 +583,7 @@ async function setup() {
444
583
  // with OP_NO_TRUST. This is the single most common reason a first real
445
584
  // payment is rejected, so it happens here, once, at setup.
446
585
  if (mainnet) {
447
- 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"}…`);
448
587
  const { Horizon, TransactionBuilder, Operation, Account, Networks } = await import("@stellar/stellar-sdk");
449
588
  const h = new Horizon.Server("https://horizon.stellar.org");
450
589
  const fa = await h.loadAccount(funder.publicKey());
@@ -455,14 +594,21 @@ async function setup() {
455
594
  await h.submitTransaction(t1);
456
595
  }
457
596
  await vault.trustToken(payee.publicKey(), "100000", payee);
458
- 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.
459
602
  if (issuer)
460
603
  await vault.deposit(String(daily), issuer);
461
604
  const data = {
462
605
  createdAt: new Date().toISOString(),
463
606
  network,
464
607
  vault: vault.address,
465
- 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() }) },
466
612
  policy: { maxPerPayment: budget, dailyBudget: daily, defaultPrice: mainnet ? "1" : "2" },
467
613
  payee: { address: payee.publicKey(), seed: payee.secret() },
468
614
  keys: {
@@ -607,6 +753,121 @@ async function withdraw() {
607
753
  console.log(`done. tx ${res?.hash}`);
608
754
  console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
609
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
+ }
610
871
  // ------------------------------------------------------------------------- main
611
872
  const HELP = `paydlux — get paid by AI agents, with a human in the loop.
612
873
 
@@ -629,6 +890,122 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
629
890
 
630
891
  one file holds it all: ./paydlux-vault.json (0600, never commit it)
631
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
+ }
632
1009
  export async function main() {
633
1010
  const cmd = process.argv[2];
634
1011
  if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
@@ -639,12 +1016,16 @@ export async function main() {
639
1016
  return setup();
640
1017
  if (cmd === "sell")
641
1018
  return sell();
1019
+ if (cmd === "donate")
1020
+ return donate();
642
1021
  if (cmd === "status")
643
1022
  return status();
644
1023
  if (cmd === "approver")
645
1024
  return approver();
646
1025
  if (cmd === "withdraw")
647
1026
  return withdraw();
1027
+ if (cmd === "usdc")
1028
+ return usdc();
648
1029
  bad(`unknown command "${cmd}".\n\n${HELP}`);
649
1030
  }
650
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
+ };