@paydlux/agent 0.1.18 → 0.1.20
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 +404 -23
- package/dist/desk.js +4 -0
- package/dist/donate.d.ts +48 -0
- package/dist/donate.js +232 -0
- package/dist/phone.d.ts +7 -0
- package/dist/phone.js +7 -1
- package/dist/qr.d.ts +12 -0
- package/dist/qr.js +52 -0
- package/dist/receive.d.ts +147 -0
- package/dist/receive.js +348 -0
- package/dist/showcase.d.ts +54 -0
- package/dist/showcase.js +385 -0
- package/dist/tunnel.d.ts +20 -0
- package/dist/tunnel.js +40 -2
- package/dist/vault.d.ts +42 -2
- package/dist/vault.js +109 -12
- package/package.json +20 -3
- package/phone-app/app.js +12 -1
- package/phone-app/index.html +1 -0
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.js";
|
|
28
|
+
import { DEMO_HOST, DEMO_ROUTE, startDemo } from "./showcase.js";
|
|
29
|
+
import { DONATE_HOST, DONATE_PORT, startDonate } from "./donate.js";
|
|
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: [
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
+
const toFund = [principal, approver, payee, ...(issuer ? [issuer] : [])];
|
|
521
|
+
for (const k of toFund)
|
|
427
522
|
await StellarVault.fundTestnet(k);
|
|
428
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
|
@@ -641,6 +879,13 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
641
879
|
--max / --daily are your spending mandate: no single payment above --max,
|
|
642
880
|
and nothing past --daily in a day. The desk refuses at the approve button,
|
|
643
881
|
so a runaway agent cannot drain the vault even if you approve in error.
|
|
882
|
+
paydlux donate [--file ./paydlux-vault.json] [--usdc] [--price 1] [--tls]
|
|
883
|
+
a public door where other agents pay YOU from THEIR vault.
|
|
884
|
+
--usdc prices the door in Circle USDC. It refuses to start unless the
|
|
885
|
+
vault already holds a USDC trustline: without one every payment bounces
|
|
886
|
+
with OP_NO_TRUST and the page looks broken forever.
|
|
887
|
+
paydlux usdc [--file ./paydlux-vault.json]
|
|
888
|
+
add that USDC trustline — one claimable, so the human half signs it.
|
|
644
889
|
paydlux status
|
|
645
890
|
balances, signers, live mandates, ledger rent.
|
|
646
891
|
paydlux approver [--forget]
|
|
@@ -652,6 +897,138 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
652
897
|
|
|
653
898
|
one file holds it all: ./paydlux-vault.json (0600, never commit it)
|
|
654
899
|
`;
|
|
900
|
+
/**
|
|
901
|
+
* `paydlux donate` — the public donation door, on its own.
|
|
902
|
+
*
|
|
903
|
+
* This is deliberately NOT `sell --donate`. Three reasons:
|
|
904
|
+
*
|
|
905
|
+
* 1. It reads no private key. The door's whole security property is that it
|
|
906
|
+
* cannot sign anything: it verifies an incoming payment against the public
|
|
907
|
+
* ledger and serves content only if the ledger says we were paid. Loading the
|
|
908
|
+
* vault file's seeds into this process would throw that property away, so
|
|
909
|
+
* only the public address, network and token code are read here.
|
|
910
|
+
* 2. It can run on the real network while the free showcase runs on testnet.
|
|
911
|
+
* The demo spends the seller's money and must stay play-money; the donation
|
|
912
|
+
* door has to take real XLM. They are separate processes on separate ports
|
|
913
|
+
* behind the same tunnel.
|
|
914
|
+
* 3. It has no gateway, no desk and no mandate, because nothing here spends the
|
|
915
|
+
* seller's money. Fewer moving parts means less that can leak.
|
|
916
|
+
*/
|
|
917
|
+
async function donate() {
|
|
918
|
+
if (flag("help") || process.argv.includes("-h")) {
|
|
919
|
+
console.log(`paydlux donate [--file ./paydlux-vault.json] [--usdc] [--price 1]
|
|
920
|
+
[--tls] [--port ${DONATE_PORT}]
|
|
921
|
+
a public door where other agents pay YOU from THEIR vault.
|
|
922
|
+
|
|
923
|
+
--usdc price the door in Circle USDC instead of the vault file's
|
|
924
|
+
token. Refuses to start unless the vault already holds a
|
|
925
|
+
USDC trustline (run: paydlux usdc) — without one every
|
|
926
|
+
payment bounces with OP_NO_TRUST and the page looks broken.
|
|
927
|
+
--price N what a donation costs, in the asset's own units.
|
|
928
|
+
--tls required in practice: an order and its memo over plain HTTP
|
|
929
|
+
can be taken by anyone on the path.
|
|
930
|
+
--port N local port (default ${DONATE_PORT}); the public name is
|
|
931
|
+
${DONATE_HOST}, routed by the tunnel.`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const path = arg("file") ?? VAULT_FILE_DEFAULT;
|
|
935
|
+
if (!existsSync(path))
|
|
936
|
+
bad(`no vault file at ${path}. Run: paydlux setup`);
|
|
937
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
938
|
+
const network = data.network === "public" ? "public" : "testnet";
|
|
939
|
+
// Public fields only. If this file ever gains a field this code needs a seed
|
|
940
|
+
// for, that is a bug to fix here, not a reason to load the seeds.
|
|
941
|
+
const payee = typeof data.payee === "string"
|
|
942
|
+
? data.payee
|
|
943
|
+
: data.payee?.address;
|
|
944
|
+
if (!payee)
|
|
945
|
+
bad(`the vault file has no payee address. Re-run: paydlux setup`);
|
|
946
|
+
// `vault` in the file is the account's public address (a string). Accept an
|
|
947
|
+
// object form too, but never read a seed from either.
|
|
948
|
+
const vaultAddress = typeof data.vault === "string"
|
|
949
|
+
? data.vault
|
|
950
|
+
: data.vault?.address ?? data.vault?.publicKey;
|
|
951
|
+
if (!/^G[A-Z2-7]{55}$/.test(vaultAddress ?? "")) {
|
|
952
|
+
bad(`the vault file's address is not a valid Stellar account: ${vaultAddress}`);
|
|
953
|
+
}
|
|
954
|
+
let assetCode = data.token?.code ?? "XLM";
|
|
955
|
+
let assetIssuer = data.token?.issuer;
|
|
956
|
+
// --usdc prices this door in real Circle USDC instead of whatever the vault
|
|
957
|
+
// file says. Two things have to be true before that is safe to serve, and
|
|
958
|
+
// both are checked against the ledger rather than assumed:
|
|
959
|
+
//
|
|
960
|
+
// 1. the vault must already hold a USDC trustline. Without it every payment
|
|
961
|
+
// bounces with OP_NO_TRUST, the donor's money comes back, and the page
|
|
962
|
+
// still says "not paid yet" forever — a door that looks broken and is.
|
|
963
|
+
// 2. the payee the 402 terms name must hold one too. Here the payee IS the
|
|
964
|
+
// vault, so one check covers both, but the check is written for the
|
|
965
|
+
// account the terms actually name so it stays true if that ever changes.
|
|
966
|
+
if (flag("usdc")) {
|
|
967
|
+
if (assetCode !== "USDC")
|
|
968
|
+
log(`pricing in USDC instead of the file's ${assetCode}`);
|
|
969
|
+
assetCode = "USDC";
|
|
970
|
+
assetIssuer = USDC_ISSUER[network];
|
|
971
|
+
const h = network === "public" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org";
|
|
972
|
+
const acc = await fetch(`${h}/accounts/${vaultAddress}`).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
973
|
+
const line = acc?.balances?.find((b) => b.asset_type !== "native"
|
|
974
|
+
&& b.asset_code === "USDC" && b.asset_issuer === assetIssuer);
|
|
975
|
+
if (!line) {
|
|
976
|
+
bad(`${vaultAddress} has no USDC trustline on ${network}, so a USDC payment would be\n`
|
|
977
|
+
+ " rejected by the ledger (OP_NO_TRUST) and the donor's money would come back.\n\n"
|
|
978
|
+
+ " add it first:\n\n"
|
|
979
|
+
+ ` paydlux usdc --file ${path}\n`);
|
|
980
|
+
}
|
|
981
|
+
log(`USDC trustline confirmed on the ledger. Circle is the issuer: ${assetIssuer}`);
|
|
982
|
+
}
|
|
983
|
+
const price = arg("price") ?? data.policy?.defaultPrice ?? "1";
|
|
984
|
+
if (!(Number(price) > 0))
|
|
985
|
+
bad("--price must be a positive number");
|
|
986
|
+
const tls = flag("tls") || !process.env.PAYDLUX_NO_TLS;
|
|
987
|
+
if (tls) {
|
|
988
|
+
const problem = tunnelProblem();
|
|
989
|
+
if (problem)
|
|
990
|
+
bad("--tls cannot run here: " + problem);
|
|
991
|
+
if (!tunnelRunning())
|
|
992
|
+
runTunnel();
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
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.");
|
|
996
|
+
}
|
|
997
|
+
const dPort = Number(arg("port") ?? DONATE_PORT);
|
|
998
|
+
const who = await portHolder(dPort);
|
|
999
|
+
if (who) {
|
|
1000
|
+
bad(`port ${dPort} (donations) is already in use by ${who}.\n`
|
|
1001
|
+
+ ` a donation door is already running. Stop it first, or this one answers nothing\n`
|
|
1002
|
+
+ ` and https://${DONATE_HOST} returns 502 with no error in this window.`);
|
|
1003
|
+
}
|
|
1004
|
+
// The vault address IS the payee here. Two reasons, and both matter:
|
|
1005
|
+
//
|
|
1006
|
+
// 1. Money lands straight in the 2-of-2. The gateway's payee account is a
|
|
1007
|
+
// plain single-key account whose seed sits in a file on this machine, and
|
|
1008
|
+
// it exists so the gateway can pay OUT without the vault signing every
|
|
1009
|
+
// time. A donation has no reason to pass through it, so it does not.
|
|
1010
|
+
// 2. One address on the page. The QR, the printed address and the 402 terms
|
|
1011
|
+
// all name the same account, so a human scanning the code and an agent
|
|
1012
|
+
// following the 402 loop cannot end up paying into different places.
|
|
1013
|
+
const door = startDonate({
|
|
1014
|
+
port: dPort, hostname: DONATE_HOST, payee: vaultAddress, network, price,
|
|
1015
|
+
asset: assetIssuer ? { code: assetCode, issuer: assetIssuer } : { code: assetCode },
|
|
1016
|
+
vault: vaultAddress,
|
|
1017
|
+
});
|
|
1018
|
+
await door.listen(dPort);
|
|
1019
|
+
const live = tls ? await tunnelLive(DONATE_HOST, 12) : false;
|
|
1020
|
+
console.log(`
|
|
1021
|
+
donation door is ${live ? `LIVE at https://${DONATE_HOST}` : `listening on 127.0.0.1:${dPort}`}
|
|
1022
|
+
network ${network === "public" ? "MAINNET — real money" : "testnet — play money"}
|
|
1023
|
+
price ${price} ${assetCode}
|
|
1024
|
+
paid into ${vaultAddress} (the 2-of-2 vault itself — watch every payment land here)
|
|
1025
|
+
|
|
1026
|
+
this process holds no signing key. It can only serve what the ledger confirms
|
|
1027
|
+
was paid for, and it cannot spend anything of yours.
|
|
1028
|
+
`);
|
|
1029
|
+
if (tls && !live)
|
|
1030
|
+
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}.`}`);
|
|
1031
|
+
}
|
|
655
1032
|
export async function main() {
|
|
656
1033
|
const cmd = process.argv[2];
|
|
657
1034
|
if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
|
|
@@ -662,12 +1039,16 @@ export async function main() {
|
|
|
662
1039
|
return setup();
|
|
663
1040
|
if (cmd === "sell")
|
|
664
1041
|
return sell();
|
|
1042
|
+
if (cmd === "donate")
|
|
1043
|
+
return donate();
|
|
665
1044
|
if (cmd === "status")
|
|
666
1045
|
return status();
|
|
667
1046
|
if (cmd === "approver")
|
|
668
1047
|
return approver();
|
|
669
1048
|
if (cmd === "withdraw")
|
|
670
1049
|
return withdraw();
|
|
1050
|
+
if (cmd === "usdc")
|
|
1051
|
+
return usdc();
|
|
671
1052
|
bad(`unknown command "${cmd}".\n\n${HELP}`);
|
|
672
1053
|
}
|
|
673
1054
|
// 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,
|
package/dist/donate.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Network } from "./receive.ts";
|
|
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.ts").OrderBook;
|
|
47
|
+
listen: (port: number) => Promise<void>;
|
|
48
|
+
};
|