@paydlux/agent 0.1.6 → 0.1.8

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
@@ -114,6 +114,62 @@ npm install @paydlux/agent
114
114
  import { StellarVault, Gateway, PaydluxAgent } from "@paydlux/agent";
115
115
  ```
116
116
 
117
+ ### Seller setup, in full (the four things that bite)
118
+
119
+ A stranger following only the previous version of this README hit four walls.
120
+ All four are in the code; here they are up front.
121
+
122
+ ```ts
123
+ import { Keypair } from "@stellar/stellar-sdk";
124
+ import { StellarVault, Gateway } from "@paydlux/agent";
125
+
126
+ const issuer = Keypair.random(), principal = Keypair.random();
127
+ const approver = Keypair.random(), agent = Keypair.random();
128
+ const payee = Keypair.random();
129
+
130
+ await StellarVault.fundTestnet(issuer); // testnet only
131
+ await StellarVault.fundTestnet(payee);
132
+ const vault = await StellarVault.create({
133
+ network: "testnet", issuer, principal, approver, agent, tokenCode: "USDC",
134
+ });
135
+ await vault.trustToken(payee.publicKey(), "1000", payee); // payee must trust the token
136
+ await vault.deposit("50", issuer); // vault must hold the money
137
+
138
+ const gw = new Gateway({
139
+ vault, network: "testnet", payee: payee.publicKey(),
140
+ routes: [{ uri: "/api/data", price: "1.00", mode: "approval" }],
141
+ principal: principal.secret(),
142
+ approver: approver.secret(), // omit this + set `split: true` to approve on a phone
143
+ agentKeys: [agent.publicKey()], // (1) without it, no agent can park a payment
144
+ deskPort: 4022,
145
+ portalKey: newPortalKey(), // (2) required before the desk is public
146
+ deskUrl: "https://desk.example.com", // (3) what the buyer is told, not loopback
147
+ });
148
+
149
+ await gw.listen(8080, (uri) => myPayload(uri));
150
+ await gw.desk.listen(4022); // (4) gw.listen() does NOT start the desk
151
+ ```
152
+
153
+ 1. **`agentKeys`** — an intent is only honoured if signed by a listed key. Empty
154
+ list means nothing can request approval, and the failure looks like a plain
155
+ 402 with no explanation.
156
+ 2. **`portalKey`** — the approval page has no login. The key *is* the
157
+ authentication, so it is a 22-character secret in the link. A desk configured
158
+ with a public hostname refuses to start without one.
159
+ 3. **`deskUrl`** — the 202 the buyer gets says where a human approves. It
160
+ defaults to `http://127.0.0.1:4022`, which is only right when buyer and
161
+ seller share a machine.
162
+ 4. **`gw.desk.listen(port)`** — the gateway and the desk are two HTTP servers.
163
+ Starting the gateway alone leaves the queue unreachable.
164
+
165
+ The buyer side needs the agent **secret**, not the vault's signer list:
166
+
167
+ ```ts
168
+ const agent = new PaydluxAgent({ agentKey: process.env.AGENT_KEY! });
169
+ const res = await agent.get("https://seller.example.com/api/data", { mandate });
170
+ if (!res.ok && res.handle) console.log("parked, awaiting a human at", res.desk);
171
+ ```
172
+
117
173
  `npm run build` produces `dist/`. The package depends only on
118
174
  `@stellar/stellar-sdk` — no transitive `toml`, so `npm audit` is clean.
119
175
 
package/dist/agent.d.ts CHANGED
@@ -19,7 +19,10 @@ export type PayResponse<T = string> = {
19
19
  tx?: string;
20
20
  /** set when a human has to approve */
21
21
  handle?: string;
22
+ /** where a human approves (the seller's desk) */
22
23
  desk?: string;
24
+ /** where THIS buyer polls for the answer (the gateway, no key needed) */
25
+ statusUrl?: string;
23
26
  reason?: string;
24
27
  requirements?: any;
25
28
  };
@@ -44,6 +47,13 @@ export declare class PaydluxAgent {
44
47
  /**
45
48
  * Wait for a human to decide on an approval-mode payment.
46
49
  * Returns the settled record, or null on deny/timeout.
50
+ *
51
+ * Pass the response you got from get() — that is the whole point of the
52
+ * handle. It carries the exact URL to poll. Poll the GATEWAY, never the desk:
53
+ * the desk is the human's surface, gated behind a portal key, and whoever
54
+ * holds that key can approve. A buyer must never have it. The gateway's
55
+ * status route answers for a handle it handed out and reveals only what the
56
+ * buyer already knows (id, price, status) plus the tx hash once it settles.
47
57
  */
48
- waitForApproval(desk: string, handle: string): Promise<any>;
58
+ waitForApproval(res: PayResponse | string, handle?: string): Promise<any>;
49
59
  }
package/dist/agent.js CHANGED
@@ -40,8 +40,15 @@ export class PaydluxAgent {
40
40
  headers: { [PAY_HEADER]: JSON.stringify({ intent }) },
41
41
  });
42
42
  const j = await parked.json().catch(() => ({}));
43
- return { ok: false, status: parked.status, handle: j.handle, desk: j.desk, requirements: reqs,
44
- reason: "a human has to approve this payment" };
43
+ // A 200 here means the human already approved this request — the seller
44
+ // serves the content on the retry. Reporting "needs approval" in that case
45
+ // would leave a buyer polling forever for a payment that is already paid.
46
+ if (parked.status === 200) {
47
+ return { ok: true, status: 200, data: (j.data ?? j), tx: j.tx };
48
+ }
49
+ return { ok: false, status: parked.status, handle: j.handle, desk: j.desk,
50
+ statusUrl: j.statusUrl, requirements: reqs,
51
+ reason: j.reason ?? "a human has to approve this payment" };
45
52
  }
46
53
  if (!opts.mandate) {
47
54
  return { ok: false, status: 402, requirements: reqs,
@@ -77,18 +84,38 @@ export class PaydluxAgent {
77
84
  /**
78
85
  * Wait for a human to decide on an approval-mode payment.
79
86
  * Returns the settled record, or null on deny/timeout.
87
+ *
88
+ * Pass the response you got from get() — that is the whole point of the
89
+ * handle. It carries the exact URL to poll. Poll the GATEWAY, never the desk:
90
+ * the desk is the human's surface, gated behind a portal key, and whoever
91
+ * holds that key can approve. A buyer must never have it. The gateway's
92
+ * status route answers for a handle it handed out and reveals only what the
93
+ * buyer already knows (id, price, status) plus the tx hash once it settles.
80
94
  */
81
- async waitForApproval(desk, handle) {
95
+ async waitForApproval(res, handle) {
82
96
  const f = this.cfg.fetch ?? fetch;
97
+ const url = typeof res === "string"
98
+ ? `${res.replace(/\/+$/, "")}/pay/status?handle=${encodeURIComponent(handle ?? "")}`
99
+ : (res.statusUrl ?? `${res.desk?.replace(/\/+$/, "")}/list`);
100
+ const want = typeof res === "string" ? handle : res.handle;
101
+ if (!want)
102
+ throw new Error("nothing to wait on: this response has no payment handle");
83
103
  const deadline = Date.now() + this.cfg.approvalTimeoutMs;
84
104
  while (Date.now() < deadline) {
85
- const r = await f(`${desk}/list`).then((x) => x.json()).catch(() => []);
86
- const rec = r.find((x) => x.id === handle);
87
- if (rec && rec.status === "settled")
88
- return rec;
89
- if (rec && (rec.status === "denied" || rec.status === "failed"))
90
- return rec;
91
- await new Promise((res) => setTimeout(res, this.cfg.pollMs));
105
+ const r = await f(url).catch(() => null);
106
+ if (!r?.ok) {
107
+ // A desk /list fallback needs the portal key, so it 403s for a buyer.
108
+ // That is correct behaviour, not a bug to work around.
109
+ if (url.endsWith("/list"))
110
+ throw new Error("cannot poll the desk for status — pass the response from get(), which carries statusUrl");
111
+ await new Promise((res2) => setTimeout(res2, this.cfg.pollMs));
112
+ continue;
113
+ }
114
+ const rec = await r.json().catch(() => null);
115
+ const found = Array.isArray(rec) ? rec.find((x) => x.id === want) : rec;
116
+ if (found && found.status !== "pending" && found.status !== "awaiting_phone")
117
+ return found;
118
+ await new Promise((res2) => setTimeout(res2, this.cfg.pollMs));
92
119
  }
93
120
  return null;
94
121
  }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ export declare const VAULT_FILE_DEFAULT = "paydlux-vault.json";
3
+ /** The one file that holds everything. Refuses to read a file that is world-readable. */
4
+ export declare function loadVault(path?: string): {
5
+ path: string;
6
+ data: any;
7
+ };
8
+ export declare function main(): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,443 @@
1
+ #!/usr/bin/env node
2
+ // paydlux — the seller app. One command, no repo checkout, no config file to
3
+ // hand-edit.
4
+ //
5
+ // paydlux setup create a vault (testnet by default)
6
+ // paydlux sell start the gateway + approval desk
7
+ // paydlux status what a vault holds and who can sign for it
8
+ //
9
+ // Everything reads one file, ./paydlux-vault.json, which `setup` writes. That
10
+ // file holds the vault's private keys, so it is 0600 and it is in .gitignore.
11
+ //
12
+ // This is the difference between a demo and a product: before, running Paydlux
13
+ // meant reading stellar/demo.ts and editing an array of routes in TypeScript.
14
+ // A seller should type one command and get a price, a link, and a key to paste
15
+ // into their bot.
16
+ import { createInterface } from "node:readline/promises";
17
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import { Keypair } from "@stellar/stellar-sdk";
20
+ import { StellarVault, USDC_ISSUER } from "./vault.js";
21
+ import { Gateway } from "./gateway.js";
22
+ import { newPortalKey } from "./desk.js";
23
+ import { PolicyEngine } from "./policy.js";
24
+ import { PUBLIC_HOST, runTunnel, tunnelLive, tunnelProblem } from "./tunnel.js";
25
+ export const VAULT_FILE_DEFAULT = "paydlux-vault.json";
26
+ const arg = (name) => {
27
+ const i = process.argv.indexOf(`--${name}`);
28
+ return i > -1 ? process.argv[i + 1] : undefined;
29
+ };
30
+ const flag = (name) => process.argv.includes(`--${name}`);
31
+ const log = (...a) => console.log("•", ...a);
32
+ /** Print a reason and stop. Declared as a function (not an arrow in a const) so
33
+ * TypeScript's control-flow analysis actually narrows the caller after it. */
34
+ function bad(msg) { console.error("\n" + msg + "\n"); process.exit(1); }
35
+ /** The one file that holds everything. Refuses to read a file that is world-readable. */
36
+ export function loadVault(path = resolve(process.cwd(), arg("file") ?? VAULT_FILE_DEFAULT)) {
37
+ if (!existsSync(path)) {
38
+ bad(`no vault here. Run: paydlux setup`);
39
+ }
40
+ const data = JSON.parse(readFileSync(path, "utf8"));
41
+ if (!data.keys?.principal)
42
+ bad(`${path} has no keys.principal — it is not a Paydlux vault file`);
43
+ return { path, data };
44
+ }
45
+ function pub(seed) { return Keypair.fromSecret(seed).publicKey(); }
46
+ /**
47
+ * Persist the portal key so the phone link survives a restart. A new key every
48
+ * boot means the human re-scans a QR every time and any link already opened on
49
+ * a phone stops working mid-payment.
50
+ */
51
+ function deskKeys(path, data) {
52
+ if (!data.desk)
53
+ data.desk = {};
54
+ if (!data.desk.portalKey) {
55
+ data.desk.portalKey = newPortalKey();
56
+ data.desk.createdAt = new Date().toISOString();
57
+ writeFileSync(path, JSON.stringify(data, null, 2));
58
+ chmodSync(path, 0o600);
59
+ }
60
+ return data.desk.portalKey;
61
+ }
62
+ // ------------------------------------------------------------------------ sell
63
+ async function sell() {
64
+ const { path, data } = loadVault();
65
+ const network = (data.network ?? "testnet");
66
+ const keys = data.keys;
67
+ const payee = data.payee?.address;
68
+ if (!payee)
69
+ bad(`${path} has no payee.address — where should payment land?\n add it, or re-run: paydlux setup`);
70
+ const price = arg("price") ?? data.policy?.defaultPrice ?? "1";
71
+ if (!(Number(price) > 0))
72
+ bad("--price must be a positive number");
73
+ const uri = arg("route") ?? "/api/data";
74
+ const mode = flag("preauth") ? "preauth" : "approval";
75
+ const tls = flag("tls");
76
+ const split = flag("split");
77
+ // Real money gets a hard ceiling, and it is not a suggestion. The ledger
78
+ // already caps a single payment at the mandate amount; what it cannot do is
79
+ // count payments. So the ceiling lives here, and it is applied before the
80
+ // desk is even built.
81
+ const maxPer = Number(arg("max") ?? data.policy?.maxPerPayment ?? 2);
82
+ const daily = Number(arg("daily") ?? data.policy?.dailyBudget ?? 10);
83
+ const policy = new PolicyEngine({ maxPerPayment: maxPer, dailyBudget: daily });
84
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: pub(keys.approver), agent: pub(keys.agent) }, network);
85
+ if (data.token.code === "USDC")
86
+ vault.useUsdc();
87
+ if (network === "public") {
88
+ console.log(`
89
+ MAINNET — real money.
90
+ token ${data.token.code}
91
+ max per payment ${maxPer}
92
+ daily budget ${daily}
93
+ vault ${vault.address}
94
+ balance ${await vault.balance()} ${data.token.code}
95
+ `);
96
+ if (!flag("yes") && process.stdin.isTTY) {
97
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
98
+ const ans = await rl.question(" every payment here is real and irreversible. continue? [y/N] ");
99
+ rl.close();
100
+ if (!/^y(es)?$/i.test(ans.trim()))
101
+ bad("stopped. nothing was started.");
102
+ }
103
+ }
104
+ // A vault that cannot pay its ledger rent stops working with a confusing
105
+ // error, so say what is actually wrong before starting anything.
106
+ const rent = await vaultRent(vault);
107
+ if (rent.short) {
108
+ bad(`the vault is short of XLM for its own ledger rent.\n needs ${rent.min} XLM, has ${rent.xlm} XLM.\n send more XLM to ${vault.address} and try again.`);
109
+ }
110
+ const portalKey = deskKeys(path, data);
111
+ const publicHost = tls ? PUBLIC_HOST : (arg("host") ?? undefined);
112
+ const deskPort = Number(arg("desk") ?? 4022);
113
+ const gwPort = Number(arg("port") ?? 4021);
114
+ const gw = new Gateway({
115
+ vault, network, payee,
116
+ deskPort,
117
+ principal: keys.principal,
118
+ // --split is the honest mode: this process cannot approve anything.
119
+ approver: split ? undefined : keys.approver,
120
+ split,
121
+ portalKey,
122
+ publicHost,
123
+ agentKeys: [pub(keys.agent)],
124
+ routes: [{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600) }],
125
+ });
126
+ let tunnel = null;
127
+ if (tls) {
128
+ const problem = tunnelProblem();
129
+ if (problem)
130
+ bad("--tls cannot run here: " + problem);
131
+ tunnel = runTunnel();
132
+ }
133
+ await gw.listen(gwPort, (u) => JSON.stringify({
134
+ served: u, at: new Date().toISOString(),
135
+ note: "paid through Paydlux; the payment is on the Stellar ledger",
136
+ }));
137
+ await gw.desk.listen(deskPort);
138
+ const deskUrl = tls ? `https://${PUBLIC_HOST}` : `http://127.0.0.1:${deskPort}`;
139
+ if (tls) {
140
+ const live = await tunnelLive();
141
+ log(live ? `desk is live at https://${PUBLIC_HOST}` : `tunnel started but https://${PUBLIC_HOST} is not answering yet`);
142
+ if (!live) {
143
+ const why = tunnel?.lastError();
144
+ log(why ? "tunnel said: " + why : "give it a few seconds, then open the link");
145
+ }
146
+ }
147
+ console.log(`
148
+ selling ${uri} for ${price} ${data.token.code} (${mode})
149
+ gateway http://127.0.0.1:${gwPort}${uri}
150
+ approvals ${deskUrl}/?k=${portalKey}
151
+ phone ${deskUrl}/phone/?k=${portalKey}
152
+
153
+ paste into your bot:
154
+ import { PaydluxAgent } from "@paydlux/agent";
155
+ const agent = new PaydluxAgent({ agentKey: "${keys.agent}", network: "${network}" });
156
+ const res = await agent.get("http://127.0.0.1:${gwPort}${uri}");
157
+
158
+ ${split
159
+ ? "approvals need your phone. this machine holds one of the two keys."
160
+ : "WARNING: this process holds BOTH human keys. Anyone with this machine can approve. Use --split for anything real."}
161
+ policy: max ${maxPer} per payment, ${daily} per day
162
+ ctrl-c to stop
163
+ `);
164
+ // The desk is a queue, not a database. If it dies with a payment parked, the
165
+ // buyer is waiting on nothing, so say so rather than exiting silently.
166
+ const bye = () => {
167
+ const open = gw.desk.list().filter((r) => r.status === "pending" || r.status === "awaiting_phone");
168
+ if (open.length)
169
+ console.log(`\n stopping with ${open.length} payment(s) still waiting: ${open.map((r) => r.id).join(", ")}`);
170
+ if (tunnel && !tunnel.proc.killed)
171
+ tunnel.proc.kill("SIGTERM");
172
+ process.exit(0);
173
+ };
174
+ process.on("SIGINT", bye);
175
+ process.on("SIGTERM", bye);
176
+ }
177
+ /** XLM the vault must hold to exist, versus what it actually has. */
178
+ async function vaultRent(vault) {
179
+ const acc = await vault.horizon.loadAccount(vault.address);
180
+ const xlm = acc.balances.find((b) => b.asset_type === "native")?.balance ?? "0";
181
+ const min = ((2 + (acc.subentry_count ?? 0)) * 0.5).toFixed(1);
182
+ return { xlm, min, short: Number(xlm) < Number(min) };
183
+ }
184
+ // ---------------------------------------------------------------------- status
185
+ async function status() {
186
+ const { data } = loadVault();
187
+ const network = (data.network ?? "testnet");
188
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(data.keys.principal), approver: pub(data.keys.approver), agent: pub(data.keys.agent) }, network);
189
+ if (data.token.code === "USDC")
190
+ vault.useUsdc();
191
+ const rent = await vaultRent(vault);
192
+ const signers = await vault.signerSummary();
193
+ const live = signers.signers.filter((s) => s.type === "preauth-mandate");
194
+ console.log(`
195
+ network ${network}
196
+ vault ${vault.address}
197
+ token ${data.token.code} (${data.token.issuer.slice(0, 8)}…)
198
+ balance ${await vault.balance()} ${data.token.code}
199
+ xlm rent ${rent.xlm} / ${rent.min} XLM ${rent.short ? "SHORT — payments will fail" : "ok"}
200
+ payee ${data.payee?.address ?? "(not set)"}
201
+ thresholds ${JSON.stringify(signers.thresholds)}
202
+ signers ${signers.signers.map((s) => `${s.type}${s.weight === 0 ? " (off)" : ""}`).join(", ")}
203
+ live mandates ${live.length}
204
+ explorer https://stellar.expert/explorer/${network === "public" ? "account" : "testnet/account"}/${vault.address}
205
+ `);
206
+ }
207
+ // ----------------------------------------------------------------------- setup
208
+ /**
209
+ * Create a vault. Testnet is the default and needs nothing from you.
210
+ * --mainnet is a different conversation: it needs a wallet to fund the accounts
211
+ * and it puts real XLM and real USDC into play, so it asks before spending.
212
+ */
213
+ async function setup() {
214
+ const mainnet = flag("mainnet");
215
+ const network = mainnet ? "public" : "testnet";
216
+ const file = resolve(process.cwd(), arg("file") ?? VAULT_FILE_DEFAULT);
217
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
218
+ const ask = async (q, def) => process.stdin.isTTY ? (await rl.question(`${q} [${def}]: `)).trim() || def : def;
219
+ if (existsSync(file) && !flag("force")) {
220
+ const ans = await ask(`${file} already exists. Overwrite? [y/N] `, "n");
221
+ if (!/^y(es)?$/i.test(ans.trim())) {
222
+ console.log("keeping the existing vault.");
223
+ rl.close();
224
+ return;
225
+ }
226
+ }
227
+ const code = ((arg("token") ?? await ask("token for your payments", mainnet ? "USDC" : "AGPAY")) || "AGPAY").toUpperCase();
228
+ const budget = Number(arg("max") ?? await ask("max per payment", mainnet ? "2" : "10"));
229
+ const daily = Number(arg("daily") ?? await ask("daily budget", mainnet ? "10" : "50"));
230
+ if (mainnet && code !== "USDC") {
231
+ 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.");
232
+ }
233
+ rl.close();
234
+ console.log(`\nPaydlux setup — Stellar ${network}${mainnet ? " (REAL MONEY)" : ""}\n`);
235
+ const principal = Keypair.random(), approver = Keypair.random(), agent = Keypair.random();
236
+ const payee = Keypair.random();
237
+ const issuer = code === "USDC" ? null : Keypair.random();
238
+ // The account that pays the XLM for creating everything. On testnet that is
239
+ // friendbot. On mainnet it has to be a wallet the human already controls.
240
+ let funder;
241
+ if (!mainnet) {
242
+ if (!issuer)
243
+ throw new Error("internal: a custom token needs an issuer key");
244
+ console.log("funding on testnet (friendbot, no captcha)…");
245
+ for (const k of [issuer, principal, approver, payee])
246
+ await StellarVault.fundTestnet(k);
247
+ funder = issuer;
248
+ }
249
+ else {
250
+ const seed = arg("fund-seed") ?? process.env.PAYDLUX_FUND_SEED;
251
+ if (!seed) {
252
+ bad(`mainnet needs a wallet to pay the account-creation costs.
253
+
254
+ paydlux setup --mainnet --fund-seed S…
255
+
256
+ The wallet only needs a few XLM (about 8 XLM covers everything here, at
257
+ ~0.17 USDT per XLM). It is used to CREATE two accounts; it cannot touch the
258
+ vault afterwards, because the vault's own keys are made fresh right now and
259
+ its master key is switched off in the same transaction that creates it.`);
260
+ }
261
+ funder = Keypair.fromSecret(seed);
262
+ const h = new (await import("@stellar/stellar-sdk")).Horizon.Server("https://horizon.stellar.org");
263
+ const acc = await h.loadAccount(funder.publicKey()).catch(() => null);
264
+ if (!acc)
265
+ bad(`the funding wallet ${funder.publicKey()} does not exist on mainnet. Send it some XLM first.`);
266
+ const xlm = acc.balances.find((b) => b.asset_type === "native")?.balance ?? "0";
267
+ if (Number(xlm) < 8)
268
+ bad(`the funding wallet has ${xlm} XLM. Creating a vault + a payee account needs about 8 XLM.`);
269
+ console.log(`funding wallet ${funder.publicKey()} — ${xlm} XLM`);
270
+ }
271
+ console.log("creating the 2-of-2 vault…");
272
+ const vault = await StellarVault.create({
273
+ network,
274
+ issuer: funder,
275
+ principal, approver, agent,
276
+ tokenCode: code,
277
+ tokenIssuer: code === "USDC" ? USDC_ISSUER[network] : undefined,
278
+ // mainnet: 2 base reserves + 3 signers + 1 trustline = 3 XLM locked, plus
279
+ // room for one live mandate (0.5 XLM) and fees. 5 leaves a margin.
280
+ startingBalance: mainnet ? "5" : undefined,
281
+ });
282
+ console.log("vault created. master key is off: it can never act alone again.");
283
+ // A Stellar account must exist before it can be paid, and a payment cannot
284
+ // create one — only createAccount can. And holding a token is not automatic:
285
+ // the receiving account has to open a trustline first, or the payment fails
286
+ // with OP_NO_TRUST. This is the single most common reason a first real
287
+ // payment is rejected, so it happens here, once, at setup.
288
+ if (mainnet) {
289
+ console.log("creating the payee account (2 XLM) and its USDC trustline…");
290
+ const { Horizon, TransactionBuilder, Operation, Account, Networks } = await import("@stellar/stellar-sdk");
291
+ const h = new Horizon.Server("https://horizon.stellar.org");
292
+ const fa = await h.loadAccount(funder.publicKey());
293
+ const t1 = new TransactionBuilder(new Account(funder.publicKey(), fa.sequence), { fee: "100", networkPassphrase: Networks.PUBLIC })
294
+ .addOperation(Operation.createAccount({ destination: payee.publicKey(), startingBalance: "2" }))
295
+ .setTimeout(120).build();
296
+ t1.sign(funder);
297
+ await h.submitTransaction(t1);
298
+ }
299
+ await vault.trustToken(payee.publicKey(), "100000", payee);
300
+ console.log("payee account ready and trusted for " + code + ".");
301
+ if (issuer)
302
+ await vault.deposit(String(daily), issuer);
303
+ const data = {
304
+ createdAt: new Date().toISOString(),
305
+ network,
306
+ vault: vault.address,
307
+ token: { code, issuer: code === "USDC" ? USDC_ISSUER[network] : issuer.publicKey() },
308
+ policy: { maxPerPayment: budget, dailyBudget: daily, defaultPrice: mainnet ? "1" : "2" },
309
+ payee: { address: payee.publicKey(), seed: payee.secret() },
310
+ keys: {
311
+ principal: principal.secret(),
312
+ approver: approver.secret(),
313
+ agent: agent.secret(),
314
+ ...(issuer ? { issuer: issuer.secret() } : {}),
315
+ },
316
+ explorer: `https://stellar.expert/explorer/${network === "public" ? "account" : "testnet/account"}/${vault.address}`,
317
+ };
318
+ writeFileSync(file, JSON.stringify(data, null, 2));
319
+ chmodSync(file, 0o600);
320
+ console.log(`
321
+ done.
322
+
323
+ vault account ${vault.address}
324
+ payee account ${payee.publicKey()}
325
+ balance ${await vault.balance()} ${code}
326
+ keys written to ${file} (permissions 0600 — treat like a password)
327
+ `);
328
+ if (mainnet) {
329
+ console.log(`next — put real money in it:
330
+ 1. send ${daily} USDC to ${vault.address}
331
+ (from any exchange or wallet that supports USDC on Stellar; the vault
332
+ already trusts Circle USDC, so the transfer will land)
333
+ 2. check it arrived: paydlux status
334
+ 3. start selling: paydlux sell --split --tls
335
+
336
+ keep the approver key off this machine:
337
+ paydlux sell --split prints the approver seed for your phone only
338
+ `);
339
+ }
340
+ else {
341
+ console.log(`next:
342
+ 1. start selling: paydlux sell
343
+ 2. open approvals: http://127.0.0.1:4022
344
+ 3. hand the agent key to your bot (printed by paydlux sell)
345
+
346
+ the agent key is ONE of two signatures. It cannot spend on its own.
347
+ `);
348
+ }
349
+ }
350
+ // -------------------------------------------------------------------- approver
351
+ /**
352
+ * The approver key is the human's half of the vault. While it sits in
353
+ * paydlux-vault.json on this laptop, this machine can approve its own payments
354
+ * and the 2-of-2 vault is really 1-of-1 with extra steps.
355
+ *
356
+ * `paydlux approver` prints it once so it can be put on the phone.
357
+ * `paydlux approver --forget` then deletes it from this machine for good: the
358
+ * laptop keeps the principal half, the phone holds the other, and neither can
359
+ * release money alone. That is the point of the design, so it is a command and
360
+ * not a paragraph in a README.
361
+ */
362
+ async function approver() {
363
+ const { path, data } = loadVault();
364
+ const forget = flag("forget");
365
+ const seed = data.keys?.approver;
366
+ if (!seed) {
367
+ console.log(`no approver key on this machine — approvals already need your phone.
368
+ sell with: paydlux sell --split --tls`);
369
+ return;
370
+ }
371
+ const pub = Keypair.fromSecret(seed).publicKey();
372
+ const portalKey = data.desk?.portalKey;
373
+ const host = flag("tls") || portalKey ? `https://${PUBLIC_HOST}` : `http://127.0.0.1:4022`;
374
+ console.log(`
375
+ approver key (the human half of the vault)
376
+
377
+ public ${pub}
378
+ secret ${seed}
379
+
380
+ Put that secret on the phone approver. It is the only thing that can release
381
+ money besides this laptop, so anyone holding it can spend up to your cap.
382
+
383
+ phone link: ${host}/phone/?k=${portalKey ?? "(run paydlux sell --tls first)"}
384
+ `);
385
+ if (!forget) {
386
+ console.log("this laptop still holds it, so it can still approve alone.\n"
387
+ + "when the phone is set up, run:\n\n paydlux approver --forget\n");
388
+ return;
389
+ }
390
+ // Confirm: deleting this is irreversible unless the seed was written down.
391
+ if (process.stdin.isTTY) {
392
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
393
+ const ans = await rl.question(" the phone has it and you do not need it here again. delete? [y/N] ");
394
+ rl.close();
395
+ if (!/^y(es)?$/i.test(ans.trim())) {
396
+ console.log("kept. nothing changed.");
397
+ return;
398
+ }
399
+ }
400
+ delete data.keys.approver;
401
+ data.keys.approverRemovedAt = new Date().toISOString();
402
+ writeFileSync(path, JSON.stringify(data, null, 2));
403
+ chmodSync(path, 0o600);
404
+ console.log(`
405
+ removed from ${path}. this machine can no longer approve anything by itself.
406
+ sell with: paydlux sell --split --tls
407
+ `);
408
+ }
409
+ // ------------------------------------------------------------------------- main
410
+ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
411
+
412
+ paydlux setup [--mainnet] [--token USDC] [--fund-seed S…]
413
+ create a vault. testnet by default (free, no wallet needed).
414
+ paydlux sell [--price 1] [--route /api/data] [--split] [--tls] [--preauth]
415
+ start the gateway and the approval desk.
416
+ paydlux status
417
+ balances, signers, live mandates, ledger rent.
418
+ paydlux approver [--forget]
419
+ print the human half of the vault, then optionally delete it from this
420
+ machine so only the phone can approve.
421
+
422
+ one file holds it all: ./paydlux-vault.json (0600, never commit it)
423
+ `;
424
+ export async function main() {
425
+ const cmd = process.argv[2];
426
+ if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
427
+ console.log(HELP);
428
+ return;
429
+ }
430
+ if (cmd === "setup")
431
+ return setup();
432
+ if (cmd === "sell")
433
+ return sell();
434
+ if (cmd === "status")
435
+ return status();
436
+ if (cmd === "approver")
437
+ return approver();
438
+ bad(`unknown command "${cmd}".\n\n${HELP}`);
439
+ }
440
+ // Only run when this file is the entry point, so importing it in a test is safe.
441
+ if (process.argv[1] && /cli\.(ts|js)$/.test(process.argv[1])) {
442
+ main().catch((e) => { console.error("failed:", e.message); process.exit(1); });
443
+ }
package/dist/desk.d.ts CHANGED
@@ -14,6 +14,12 @@ export type DeskRecord = {
14
14
  content?: string;
15
15
  tx?: string;
16
16
  reason?: string;
17
+ /**
18
+ * Dedupe key: same buyer + same request + same price. Without it, every retry
19
+ * after an approval parks a NEW payment, so a human approving twice pays
20
+ * twice and the buyer never gets the content they already bought.
21
+ */
22
+ key?: string;
17
23
  /** split mode: what the phone is asked to sign, and what it sent back */
18
24
  challenge?: ApprovalChallenge;
19
25
  qr?: string;
@@ -54,7 +60,10 @@ export declare class ApprovalDesk {
54
60
  private server?;
55
61
  cfg: DeskConfig;
56
62
  constructor(cfg: DeskConfig);
57
- enqueue(uri: string, price: string, body: string, content: string): string;
63
+ enqueue(uri: string, price: string, body: string, content: string, key?: string): string;
64
+ /** Every record carrying this key, newest first. */
65
+ allByKey(key: string): DeskRecord[];
66
+ findByKey(key: string): DeskRecord | null;
58
67
  get(id: string): DeskRecord | null;
59
68
  list(): DeskRecord[];
60
69
  /**
package/dist/desk.js CHANGED
@@ -130,12 +130,30 @@ export class ApprovalDesk {
130
130
  server;
131
131
  cfg;
132
132
  constructor(cfg) { this.cfg = cfg; }
133
- enqueue(uri, price, body, content) {
133
+ enqueue(uri, price, body, content, key) {
134
+ // Re-use an existing request for the same buyer + body + price. A buyer who
135
+ // retries after a human approved must get their content, not a second
136
+ // payment waiting in the queue. Settled records are NOT reused here — the
137
+ // gateway decides how long a paid-for request stays free to collect.
138
+ if (key) {
139
+ const hit = this.allByKey(key).find((r) => r.status === "pending" || r.status === "awaiting_phone");
140
+ if (hit)
141
+ return hit.id;
142
+ }
134
143
  const id = `pay_${(++this.seq).toString().padStart(4, "0")}`;
135
- this.records.set(id, { id, at: Date.now(), uri, price, body, status: "pending", content });
144
+ this.records.set(id, { id, at: Date.now(), uri, price, body, status: "pending", content, key });
136
145
  console.log(`[desk] ${id} awaiting a human: ${price} for ${uri}`);
137
146
  return id;
138
147
  }
148
+ /** Every record carrying this key, newest first. */
149
+ allByKey(key) {
150
+ if (!key)
151
+ return [];
152
+ return [...this.records.values()].filter((r) => r.key === key).sort((a, b) => b.at - a.at);
153
+ }
154
+ findByKey(key) {
155
+ return this.allByKey(key)[0] ?? null;
156
+ }
139
157
  get(id) { return this.records.get(id) ?? null; }
140
158
  list() { return [...this.records.values()]; }
141
159
  /**
@@ -417,6 +435,32 @@ export class ApprovalDesk {
417
435
  }
418
436
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
419
437
  if (m && req.method === "POST") {
438
+ // These routes move money, so they must not be reachable from another
439
+ // page. A cross-origin fetch POST is a CORS *simple request*: no
440
+ // preflight, so the attacker's page never sees the reply but the
441
+ // approval still happens. Verified end to end: a page on another port
442
+ // settled a parked payment and paid the seller. Browsers always send
443
+ // Origin on POST and Sec-Fetch-Site on every request, so checking both
444
+ // closes that path while leaving local automation (curl, tests, the
445
+ // phone app on its own origin) alone.
446
+ const origin = String(req.headers.origin ?? "");
447
+ if (origin) {
448
+ const here = String(req.headers.host ?? "").toLowerCase();
449
+ let oh;
450
+ try {
451
+ oh = new URL(origin).host.toLowerCase();
452
+ }
453
+ catch {
454
+ oh = "";
455
+ }
456
+ if (oh !== here) {
457
+ return send(403, { error: "cross_origin", reason: `this desk only accepts actions from itself, not from ${origin}` });
458
+ }
459
+ }
460
+ const site = String(req.headers["sec-fetch-site"] ?? "").toLowerCase();
461
+ if (site && site !== "same-origin" && site !== "none") {
462
+ return send(403, { error: "cross_origin", reason: `this desk only accepts same-site actions (got ${site})` });
463
+ }
420
464
  if (m[1] === "signed") {
421
465
  const body = await readBody(req);
422
466
  const { signedXdr } = JSON.parse(body || "{}");
package/dist/gateway.d.ts CHANGED
@@ -9,6 +9,11 @@ export type Route = {
9
9
  mode: "preauth" | "approval";
10
10
  /** seconds the seller's content stays reserved while a human decides */
11
11
  holdSeconds?: number;
12
+ /**
13
+ * How long one approved payment keeps buying this route. Defaults to
14
+ * holdSeconds. Set it to 0 to make every single call cost its own approval.
15
+ */
16
+ collectSeconds?: number;
12
17
  };
13
18
  export type GatewayConfig = {
14
19
  vault: StellarVault;
@@ -32,6 +37,19 @@ export type GatewayConfig = {
32
37
  portalKey?: string;
33
38
  /** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
34
39
  publicHost?: string;
40
+ /**
41
+ * Agent public keys allowed to park a payment for approval. An intent is only
42
+ * honoured if it is signed by one of these, so an unlisted key gets a plain
43
+ * 402 instead of a queue entry. Leave empty and nothing can request approval.
44
+ */
45
+ agentKeys?: string[];
46
+ /**
47
+ * Where a human approves, as the BUYER should see it. Set this to the public
48
+ * desk URL (e.g. "https://desk.paydlux.com") when the desk is reachable there;
49
+ * it is returned in the 202 so the buyer knows where to go. Defaults to the
50
+ * loopback address, which is only useful on one machine.
51
+ */
52
+ deskUrl?: string;
35
53
  };
36
54
  export type PaymentProof = {
37
55
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
@@ -56,6 +74,13 @@ export declare class Gateway {
56
74
  syncMandates(): Promise<any>;
57
75
  /** Register a pre-authorised mandate for a route (needs both human keys). */
58
76
  authorise(uri: string, spec: Parameters<StellarVault["createMandate"]>[0], principal: Parameters<StellarVault["createMandate"]>[1], approver: Parameters<StellarVault["createMandate"]>[2]): Promise<Mandate>;
77
+ /**
78
+ * This gateway's own externally visible base URL, as the client sees it.
79
+ * Behind a TLS proxy (a Cloudflare tunnel) the Host header is the public name
80
+ * and X-Forwarded-Proto says https, so links handed to a buyer point at the
81
+ * address they can actually reach instead of a loopback port.
82
+ */
83
+ private selfUrl;
59
84
  /** The intent must be signed by a real agent key on the vault. */
60
85
  private verifyIntent;
61
86
  /** Keys allowed to place approval requests. */
package/dist/gateway.js CHANGED
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // Either way the agent's key alone can never move money: threshold is 2.
15
15
  import { createServer } from "node:http";
16
+ import { createHash } from "node:crypto";
16
17
  import { Keypair, StrKey, Transaction } from "@stellar/stellar-sdk";
17
18
  import { StellarTxError, hex, passphrase } from "./vault.js";
18
19
  import { ApprovalDesk } from "./desk.js";
@@ -26,6 +27,7 @@ export class Gateway {
26
27
  cfg;
27
28
  constructor(cfg) {
28
29
  this.cfg = cfg;
30
+ this.agentKeys = cfg.agentKeys ?? [];
29
31
  for (const r of cfg.routes)
30
32
  this.routes.set(r.uri, r);
31
33
  this.desk = new ApprovalDesk({
@@ -55,31 +57,46 @@ export class Gateway {
55
57
  this.mandates.set(m.hash, m);
56
58
  return m;
57
59
  }
60
+ /**
61
+ * This gateway's own externally visible base URL, as the client sees it.
62
+ * Behind a TLS proxy (a Cloudflare tunnel) the Host header is the public name
63
+ * and X-Forwarded-Proto says https, so links handed to a buyer point at the
64
+ * address they can actually reach instead of a loopback port.
65
+ */
66
+ selfUrl(req) {
67
+ const proto = (req.headers["x-forwarded-proto"] ?? "").toString().split(",")[0].trim()
68
+ || (this.cfg.publicHost ? "https" : "http");
69
+ const host = req.headers.host ?? "127.0.0.1";
70
+ return `${proto}://${host}`;
71
+ }
58
72
  /** The intent must be signed by a real agent key on the vault. */
59
73
  verifyIntent(intent) {
60
74
  if (!intent?.payload || !intent?.signature)
61
- return false;
75
+ return null;
62
76
  const [url, amount, vault] = intent.payload.split("|");
63
77
  if (vault !== this.cfg.vault.address)
64
- return false;
78
+ return null;
65
79
  if (Number(amount) <= 0)
66
- return false;
80
+ return null;
67
81
  const age = Date.now() - Number(intent.payload.split("|").at(-1));
68
82
  if (!Number.isFinite(age) || age > 5 * 60 * 1000)
69
- return false;
83
+ return null;
70
84
  const msg = Buffer.from(intent.payload);
71
85
  const sig = Buffer.from(intent.signature, "base64");
72
86
  for (const s of this.agentKeys) {
87
+ // Returning WHICH key signed it matters downstream: the dedupe key is
88
+ // built from the buyer's identity, so two different agents asking for the
89
+ // same resource must not share one queue entry.
73
90
  try {
74
91
  if (Keypair.fromPublicKey(s).verify(msg, sig))
75
- return true;
92
+ return s;
76
93
  }
77
94
  catch { /* keep going */ }
78
95
  }
79
- return false;
96
+ return null;
80
97
  }
81
98
  /** Keys allowed to place approval requests. */
82
- agentKeys = [];
99
+ agentKeys;
83
100
  requirements(uri) {
84
101
  const r = this.routes.get(uri);
85
102
  return {
@@ -148,8 +165,21 @@ export class Gateway {
148
165
  if (uri === "/pay/status") {
149
166
  const handle = new URL(req.url, "http://x").searchParams.get("handle");
150
167
  const rec = handle ? this.desk.get(handle) : null;
168
+ // A projection, never the record. The desk record carries `content` —
169
+ // the payload the seller only ships once money has landed. Handing the
170
+ // whole object to this ungated route would let anyone holding a handle
171
+ // read the paid content while the payment still sits pending, which
172
+ // defeats the entire product. The buyer already knows the id, price and
173
+ // uri; the tx hash is theirs once it settles. Nothing else goes out.
174
+ const safe = rec
175
+ ? {
176
+ id: rec.id, status: rec.status, price: rec.price, uri: rec.uri,
177
+ ...(rec.tx ? { tx: rec.tx } : {}),
178
+ ...(rec.reason ? { reason: rec.reason } : {}),
179
+ }
180
+ : { error: "unknown handle" };
151
181
  res.writeHead(rec ? 200 : 404, { "content-type": "application/json" });
152
- res.end(JSON.stringify(rec ?? { error: "unknown handle" }));
182
+ res.end(JSON.stringify(safe));
153
183
  return;
154
184
  }
155
185
  const r = this.routes.get(uri);
@@ -184,14 +214,75 @@ export class Gateway {
184
214
  res.end(JSON.stringify({ error: "payment_invalid", reason: "approval mode needs a signed intent" }));
185
215
  return;
186
216
  }
187
- if (!this.verifyIntent(proof.intent)) {
217
+ const signer = this.verifyIntent(proof.intent);
218
+ if (!signer) {
188
219
  res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
189
220
  res.end(JSON.stringify({ error: "payment_invalid", reason: "intent signature is not from the agent key" }));
190
221
  return;
191
222
  }
192
- const handle = this.desk.enqueue(uri, r.price, body, getContent(uri));
223
+ // One request per buyer + body + price, not one per HTTP call. A buyer
224
+ // retries by design (that is how they wait for a human), so without this
225
+ // each retry parks a fresh payment: the human approves twice, money moves
226
+ // twice, and the buyer still has no content. The signer is in the key so
227
+ // two different agents never share one queue entry.
228
+ const key = createHash("sha256")
229
+ .update(`${signer}|${uri}|${r.price}|${body}`).digest("hex");
230
+ const hit = this.desk.findByKey(key);
231
+ if (hit) {
232
+ // Already paid: hand over the content. This is the whole point of the
233
+ // retry. It stays collectable for the route's hold window, then a
234
+ // fresh payment is required — otherwise one approval would buy the
235
+ // resource forever, which is not what the human agreed to.
236
+ if (hit.status === "settled") {
237
+ const windowMs = (r.collectSeconds ?? r.holdSeconds ?? 3600) * 1000;
238
+ if (Date.now() - hit.at <= windowMs) {
239
+ res.writeHead(200, {
240
+ "content-type": "application/json",
241
+ "x-payment-response": JSON.stringify({ tx: hit.tx }),
242
+ });
243
+ res.end(JSON.stringify({ data: getContent(uri), tx: hit.tx }));
244
+ return;
245
+ }
246
+ }
247
+ // A human said no. Do not silently re-open the same request and nag
248
+ // them with it: the buyer has to change the request to ask again.
249
+ if (hit.status === "denied") {
250
+ res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
251
+ res.end(JSON.stringify({
252
+ error: "payment_denied", handle: hit.id,
253
+ reason: hit.reason ?? "denied by the approver",
254
+ }));
255
+ return;
256
+ }
257
+ // pending / awaiting_phone: reuse the existing handle.
258
+ // failed: the human approved but the ledger refused, so no money moved
259
+ // and the buyer is not at fault — park a fresh one below.
260
+ if (hit.status === "pending" || hit.status === "awaiting_phone") {
261
+ const deskUrl = this.cfg.deskUrl
262
+ ?? (this.cfg.publicHost ? `https://${this.cfg.publicHost}` : `http://127.0.0.1:${this.cfg.deskPort ?? 4022}`);
263
+ res.writeHead(202, { "content-type": "application/json", "x-payment-hold": hit.id });
264
+ res.end(JSON.stringify({
265
+ status: "awaiting_approval", handle: hit.id, desk: deskUrl,
266
+ statusUrl: `${this.selfUrl(req)}/pay/status?handle=${encodeURIComponent(hit.id)}`,
267
+ }));
268
+ return;
269
+ }
270
+ }
271
+ const handle = this.desk.enqueue(uri, r.price, body, getContent(uri), key);
193
272
  res.writeHead(202, { "content-type": "application/json", "x-payment-hold": handle });
194
- res.end(JSON.stringify({ status: "awaiting_approval", handle, desk: `http://127.0.0.1:${this.cfg.deskPort ?? 4022}` }));
273
+ // The buyer needs to know where a human approves. Loopback is only
274
+ // right when both sides are on one machine; anything real sets deskUrl
275
+ // (or publicHost) so this points at the https desk.
276
+ const deskUrl = this.cfg.deskUrl
277
+ ?? (this.cfg.publicHost ? `https://${this.cfg.publicHost}` : `http://127.0.0.1:${this.cfg.deskPort ?? 4022}`);
278
+ // ...and where to poll for the answer. That is THIS gateway, not the
279
+ // desk: the desk is the human's surface and is gated behind a portal key
280
+ // the buyer must never hold. Without this the buyer has no way to learn
281
+ // that a payment was approved and waits until timeout.
282
+ res.end(JSON.stringify({
283
+ status: "awaiting_approval", handle, desk: deskUrl,
284
+ statusUrl: `${this.selfUrl(req)}/pay/status?handle=${encodeURIComponent(handle)}`,
285
+ }));
195
286
  return;
196
287
  }
197
288
  try {
@@ -0,0 +1,21 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export declare function cloudflaredPath(): string | null;
3
+ export declare const TUNNEL_NAME = "paydlux-desk";
4
+ export declare const PUBLIC_HOST = "desk.paydlux.com";
5
+ /** Why this machine cannot run the named tunnel, or null if it can. */
6
+ export declare function tunnelProblem(): string | null;
7
+ /** True if this machine can run the named tunnel. */
8
+ export declare function tunnelReady(): boolean;
9
+ /**
10
+ * Start the named tunnel and make sure it dies with us. An orphaned cloudflared
11
+ * is a public approval desk with nobody watching it, so every exit path kills
12
+ * the child.
13
+ */
14
+ export interface Tunnel {
15
+ proc: ChildProcess;
16
+ /** Why cloudflared failed to start or run, if it did. */
17
+ lastError(): string | null;
18
+ }
19
+ export declare function runTunnel(): Tunnel;
20
+ /** Is the tunnel actually serving TLS yet? Probes the real URL. */
21
+ export declare function tunnelLive(host?: string, tries?: number): Promise<boolean>;
package/dist/tunnel.js ADDED
@@ -0,0 +1,106 @@
1
+ // The named Cloudflare tunnel: real TLS on a domain you control.
2
+ //
3
+ // A trycloudflare.com URL (story.ts --public) is fine for a demo clip and wrong
4
+ // for anything else: the hostname is random and disposable, there is no HSTS,
5
+ // and nothing tells the human they reached *your* desk. A named tunnel gives
6
+ // https://desk.paydlux.com — same free tier, but the human's browser can verify
7
+ // where it is, and the desk can pin the Host header so the local port cannot be
8
+ // driven directly.
9
+ //
10
+ // One-time setup, already done on this machine:
11
+ // cloudflared tunnel login
12
+ // cloudflared tunnel create paydlux-desk
13
+ // cloudflared tunnel route dns paydlux-desk desk.paydlux.com
14
+ //
15
+ // The tunnel's credentials live in ~/.cloudflared/<tunnel-id>.json. They are NOT
16
+ // in this repo and must never be. Anyone holding that file can serve your
17
+ // hostname. Losing it means deleting the tunnel and making a new one.
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync, readdirSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ /**
23
+ * Where cloudflared actually is. A GUI-launched or login-shell PATH often lacks
24
+ * /opt/homebrew/bin, and spawn() then dies with "spawn cloudflared ENOENT" — an
25
+ * unhandled ChildProcess error that takes the whole story down with a stack
26
+ * trace. Resolve the absolute path instead of trusting PATH.
27
+ */
28
+ const CANDIDATES = [
29
+ "/opt/homebrew/bin/cloudflared", // Apple Silicon Homebrew
30
+ "/usr/local/bin/cloudflared", // Intel Homebrew
31
+ "/usr/bin/cloudflared",
32
+ join(homedir(), ".local/bin/cloudflared"),
33
+ ];
34
+ export function cloudflaredPath() {
35
+ // PATH first, so an explicit choice (or a version manager) wins.
36
+ for (const dir of (process.env.PATH ?? "").split(":")) {
37
+ if (!dir)
38
+ continue;
39
+ const p = join(dir, "cloudflared");
40
+ if (existsSync(p))
41
+ return p;
42
+ }
43
+ for (const p of CANDIDATES)
44
+ if (existsSync(p))
45
+ return p;
46
+ return null;
47
+ }
48
+ export const TUNNEL_NAME = "paydlux-desk";
49
+ export const PUBLIC_HOST = "desk.paydlux.com";
50
+ /** Why this machine cannot run the named tunnel, or null if it can. */
51
+ export function tunnelProblem() {
52
+ if (!cloudflaredPath()) {
53
+ return "cloudflared is not installed, or not on PATH. " +
54
+ "Install with: brew install cloudflared";
55
+ }
56
+ const dir = join(homedir(), ".cloudflared");
57
+ if (!existsSync(join(dir, "cert.pem"))) {
58
+ return "no Cloudflare login on this machine. Run: cloudflared tunnel login";
59
+ }
60
+ if (!existsSync(join(dir, "config.yml"))) {
61
+ return "no ~/.cloudflared/config.yml — the tunnel needs ingress to 127.0.0.1";
62
+ }
63
+ if (!readdirSync(dir).some((f) => /^[0-9a-f-]{36}\.json$/.test(f))) {
64
+ return "no tunnel credentials in ~/.cloudflared/. Run: cloudflared tunnel create " + TUNNEL_NAME;
65
+ }
66
+ return null;
67
+ }
68
+ /** True if this machine can run the named tunnel. */
69
+ export function tunnelReady() { return tunnelProblem() === null; }
70
+ export function runTunnel() {
71
+ const bin = cloudflaredPath();
72
+ if (!bin)
73
+ throw new Error("cloudflared not found — brew install cloudflared");
74
+ const cf = spawn(bin, ["tunnel", "run", TUNNEL_NAME], { stdio: ["ignore", "pipe", "pipe"] });
75
+ // Without this handler a spawn failure is an UNHANDLED 'error' event that
76
+ // crashes the process with a stack trace, after the story already funded
77
+ // accounts and printed a portal link. Capture it and report it plainly.
78
+ let failure = null;
79
+ cf.on("error", (e) => { failure = e.message; });
80
+ cf.stderr?.on("data", (b) => {
81
+ const line = b.toString().trim();
82
+ // cloudflared logs config errors to stderr; keep the last useful one.
83
+ if (line && /error|ERR|not found|invalid/i.test(line))
84
+ failure = line.slice(0, 300);
85
+ });
86
+ const close = () => { if (!cf.killed)
87
+ cf.kill("SIGTERM"); };
88
+ process.on("exit", close);
89
+ for (const sig of ["SIGINT", "SIGTERM"]) {
90
+ process.on(sig, () => { close(); process.exit(0); });
91
+ }
92
+ return { proc: cf, lastError: () => failure };
93
+ }
94
+ /** Is the tunnel actually serving TLS yet? Probes the real URL. */
95
+ export async function tunnelLive(host = PUBLIC_HOST, tries = 12) {
96
+ for (let i = 0; i < tries; i++) {
97
+ try {
98
+ const res = await fetch(`https://${host}/healthz`);
99
+ if (res.ok || res.status === 403)
100
+ return true; // 403 = desk answered, TLS works
101
+ }
102
+ catch { /* not up yet */ }
103
+ await new Promise((r) => setTimeout(r, 1500));
104
+ }
105
+ return false;
106
+ }
package/dist/vault.d.ts CHANGED
@@ -59,6 +59,13 @@ export declare class StellarVault {
59
59
  * Create a vault. `issuer` signs the token; the vault account itself is a
60
60
  * multisig whose release threshold needs principal + approver, or agent +
61
61
  * a pre-authorised mandate hash. Nothing else reaches threshold.
62
+ *
63
+ * `tokenIssuer` separates two roles that are the same account for a custom
64
+ * token and different accounts for real USDC. Circle mints USDC, so on
65
+ * mainnet the account that pays to create the vault is the seller's wallet
66
+ * while the token's issuer is Circle's — we hold no key for it and must not
67
+ * pretend to. Pass the issuer's public key there and the asset is built from
68
+ * it instead of from `issuer`.
62
69
  */
63
70
  static create(opts: {
64
71
  network: Network;
@@ -67,6 +74,7 @@ export declare class StellarVault {
67
74
  approver: Keypair;
68
75
  agent: Keypair;
69
76
  tokenCode: string;
77
+ tokenIssuer?: string;
70
78
  vaultSeed?: string;
71
79
  startingBalance?: string;
72
80
  }): Promise<StellarVault>;
package/dist/vault.js CHANGED
@@ -99,6 +99,13 @@ export class StellarVault {
99
99
  * Create a vault. `issuer` signs the token; the vault account itself is a
100
100
  * multisig whose release threshold needs principal + approver, or agent +
101
101
  * a pre-authorised mandate hash. Nothing else reaches threshold.
102
+ *
103
+ * `tokenIssuer` separates two roles that are the same account for a custom
104
+ * token and different accounts for real USDC. Circle mints USDC, so on
105
+ * mainnet the account that pays to create the vault is the seller's wallet
106
+ * while the token's issuer is Circle's — we hold no key for it and must not
107
+ * pretend to. Pass the issuer's public key there and the asset is built from
108
+ * it instead of from `issuer`.
102
109
  */
103
110
  static async create(opts) {
104
111
  const { network, issuer, principal, approver, agent } = opts;
@@ -116,7 +123,7 @@ export class StellarVault {
116
123
  // The trustline has to ride in this same transaction: once masterWeight is
117
124
  // 0 the vault seed cannot sign anything on its own, so a later changeTrust
118
125
  // would need two human signatures before the vault can even hold tokens.
119
- const token = new Asset(opts.tokenCode, issuer.publicKey());
126
+ const token = new Asset(opts.tokenCode, opts.tokenIssuer ?? issuer.publicKey());
120
127
  await sub(h, pass, (await tb(h, vault.publicKey(), undefined, pass))
121
128
  .addOperation(Operation.changeTrust({ asset: token, limit: "100000000" }))
122
129
  .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: principal.publicKey(), weight: 1 } }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Payments for AI agents that a human approves. Spending mandates enforced on the Stellar ledger: 2-of-2 vault, pre-authorised transaction envelopes, HTTP 402, USDC.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,7 +37,9 @@
37
37
  "test:stellar": "tsx stellar/demo.ts",
38
38
  "test:portal": "tsx stellar/xss-check.ts",
39
39
  "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts",
40
- "typecheck": "tsc --noEmit"
40
+ "typecheck": "tsc --noEmit",
41
+ "app": "tsx stellar/cli.ts",
42
+ "app:mainnet": "tsx stellar/cli.ts setup --mainnet"
41
43
  },
42
44
  "keywords": [
43
45
  "x402",
@@ -71,5 +73,8 @@
71
73
  "typescript": "^7.0.2",
72
74
  "viem": "^2.57.13",
73
75
  "xrpl": "^5.3.0"
76
+ },
77
+ "bin": {
78
+ "paydlux": "dist/cli.js"
74
79
  }
75
80
  }