@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 +9 -1
- package/dist/cli.js +405 -24
- 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/policy.d.ts +9 -0
- package/dist/policy.js +17 -0
- 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 +54 -2
- package/dist/vault.js +151 -9
- package/package.json +19 -3
- package/phone-app/app.js +12 -1
- package/phone-app/index.html +1 -0
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
|
|
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
|
|
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: [
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
+
const toFund = [principal, approver, payee, ...(issuer ? [issuer] : [])];
|
|
521
|
+
for (const k of toFund)
|
|
404
522
|
await StellarVault.fundTestnet(k);
|
|
405
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
package/dist/donate.d.ts
ADDED
|
@@ -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
|
+
};
|