@paydlux/agent 0.1.9 → 0.1.11

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
@@ -84,11 +84,17 @@ one key. So the approval half is split off:
84
84
  npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
85
85
  npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
86
86
  npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
87
- npx tsx phone-app/desk-app-check.ts # 28 checks: the real app over real HTTP, end to end
87
+ npx tsx phone-app/desk-app-check.ts # 57 checks: the real app over real HTTP, end to end
88
88
  ```
89
89
 
90
90
  The seller builds the payment and signs it with their own half. The human opens
91
- one link — `/phone/?id=...` — and the approver app in `phone-app/` shows them the
91
+ one link — `/phone/?k=...` — and the approver app in `phone-app/` shows them the
92
+ `https://desk.paydlux.com/phone/` link opens an installable app: it has a manifest
93
+ and icons, so a phone can put it on the home screen and it runs full-screen. The
94
+ shell (html, js, icons) is public because it holds no secret and no payment data
95
+ and must load before the key does; every route that returns data or moves money
96
+ (`/queue`, `/challenge`, `/approve`, `/signed`, `/deny`) still answers 403 without
97
+ the portal key.
92
98
  amount, the token, the payee and the deadline, then signs on the device.
93
99
 
94
100
  The app is three static files with no dependencies and no build step. It carries
package/dist/agent.d.ts CHANGED
@@ -19,6 +19,14 @@ export type PayResponse<T = string> = {
19
19
  tx?: string;
20
20
  /** set when a human has to approve */
21
21
  handle?: string;
22
+ /**
23
+ * Why a payment was refused, as the seller named it: "mandate_refused" (the
24
+ * seller's spending ceiling — retrying the same request can never work) or
25
+ * "payment_denied" (a human said no to this one). Without the distinction an
26
+ * agent retries a refused payment forever, which is exactly what a polite
27
+ * buyer does.
28
+ */
29
+ error?: string;
22
30
  /** where a human approves (the seller's desk) */
23
31
  desk?: string;
24
32
  /** where THIS buyer polls for the answer (the gateway, no key needed) */
package/dist/agent.js CHANGED
@@ -46,7 +46,7 @@ export class PaydluxAgent {
46
46
  if (parked.status === 200) {
47
47
  return { ok: true, status: 200, data: (j.data ?? j), tx: j.tx };
48
48
  }
49
- return { ok: false, status: parked.status, handle: j.handle, desk: j.desk,
49
+ return { ok: false, status: parked.status, handle: j.handle, error: j.error, desk: j.desk,
50
50
  statusUrl: j.statusUrl, requirements: reqs,
51
51
  reason: j.reason ?? "a human has to approve this payment" };
52
52
  }
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,28 @@
1
1
  #!/usr/bin/env node
2
+ import { StellarVault } from "./vault.ts";
2
3
  export declare const VAULT_FILE_DEFAULT = "paydlux-vault.json";
3
4
  /** The one file that holds everything. Refuses to read a file that is world-readable. */
4
5
  export declare function loadVault(path?: string): {
5
6
  path: string;
6
7
  data: any;
7
8
  };
9
+ /**
10
+ * Persist the portal key so the phone link survives a restart. A new key every
11
+ * boot means the human re-scans a QR every time and any link already opened on
12
+ * a phone stops working mid-payment.
13
+ */
14
+ export declare function deskKeys(path: string, data: any): string;
15
+ /**
16
+ * Where the money lands.
17
+ *
18
+ * `setup` creates a payee account for you. Two cases break that: a vault made
19
+ * before the payee existed, and a seller who wants the money in their own
20
+ * account (a Binance deposit address, an exchange wallet). Both are handled
21
+ * here, because a payment to an account that does not exist — or that does not
22
+ * trust the token — fails on the ledger with a code the seller never sees.
23
+ *
24
+ * Adding a trustline needs the payee's own key, so for an outside account we can
25
+ * only check and explain. For a payee we generated, we do it ourselves.
26
+ */
27
+ export declare function ensurePayee(path: string, data: any, vault: StellarVault): Promise<any>;
8
28
  export declare function main(): Promise<void>;
package/dist/cli.js CHANGED
@@ -17,8 +17,8 @@ import { createInterface } from "node:readline/promises";
17
17
  import { existsSync, readFileSync, writeFileSync, chmodSync, realpathSync } from "node:fs";
18
18
  import { pathToFileURL } from "node:url";
19
19
  import { resolve } from "node:path";
20
- import { Keypair } from "@stellar/stellar-sdk";
21
- import { StellarVault, USDC_ISSUER } from "./vault.js";
20
+ import { Account, Keypair, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
21
+ import { StellarVault, USDC_ISSUER, passphrase } from "./vault.js";
22
22
  import { Gateway } from "./gateway.js";
23
23
  import { newPortalKey } from "./desk.js";
24
24
  import { PolicyEngine } from "./policy.js";
@@ -49,7 +49,7 @@ function pub(seed) { return Keypair.fromSecret(seed).publicKey(); }
49
49
  * boot means the human re-scans a QR every time and any link already opened on
50
50
  * a phone stops working mid-payment.
51
51
  */
52
- function deskKeys(path, data) {
52
+ export function deskKeys(path, data) {
53
53
  if (!data.desk)
54
54
  data.desk = {};
55
55
  if (!data.desk.portalKey) {
@@ -60,14 +60,104 @@ function deskKeys(path, data) {
60
60
  }
61
61
  return data.desk.portalKey;
62
62
  }
63
+ /** Does this account already hold the token the vault pays in? */
64
+ async function trusts(vault, address) {
65
+ const acc = await vault.horizon.loadAccount(address).catch(() => null);
66
+ if (!acc)
67
+ return false;
68
+ const code = vault.token.getCode(), issuer = vault.token.getIssuer();
69
+ return acc.balances.some((b) => b.asset_type !== "native"
70
+ && b.asset_code === code && b.asset_issuer === issuer);
71
+ }
72
+ /**
73
+ * Where the money lands.
74
+ *
75
+ * `setup` creates a payee account for you. Two cases break that: a vault made
76
+ * before the payee existed, and a seller who wants the money in their own
77
+ * account (a Binance deposit address, an exchange wallet). Both are handled
78
+ * here, because a payment to an account that does not exist — or that does not
79
+ * trust the token — fails on the ledger with a code the seller never sees.
80
+ *
81
+ * Adding a trustline needs the payee's own key, so for an outside account we can
82
+ * only check and explain. For a payee we generated, we do it ourselves.
83
+ */
84
+ export async function ensurePayee(path, data, vault) {
85
+ const specified = arg("payee");
86
+ if (specified) {
87
+ if (!/^G[A-Z2-7]{55}$/.test(specified))
88
+ bad(`--payee must be a Stellar account (G…56 chars), got "${specified}"`);
89
+ if (specified === vault.address)
90
+ bad("--payee cannot be the vault itself");
91
+ if (!(await trusts(vault, specified))) {
92
+ bad(`${specified} does not hold ${vault.token.getCode()} yet, so a payment to it would be\n rejected by the ledger (OP_NO_TRUST).\n\n add a trustline for ${vault.token.getCode()} from ${vault.token.getIssuer()} to that account\n (the exchange or wallet that owns it does this), or drop --payee and Paydlux will\n create and manage a payee account for you.`);
93
+ }
94
+ data.payee = { address: specified, external: true };
95
+ writeFileSync(path, JSON.stringify(data, null, 2));
96
+ chmodSync(path, 0o600);
97
+ log(`payments land in your account ${specified}`);
98
+ return specified;
99
+ }
100
+ if (data.payee?.address && !data.payee.seed)
101
+ return data.payee.address;
102
+ if (data.payee?.address && (await trusts(vault, data.payee.address)))
103
+ return data.payee.address;
104
+ const keys = data.keys;
105
+ if (!keys.approver) {
106
+ bad(`this vault has no payee account yet, and creating one needs both human keys —\n this machine holds only one.\n\n point the payments at an account you already own that holds ${vault.token.getCode()}:\n\n paydlux sell --payee G…`);
107
+ }
108
+ const kp = Keypair.fromSecret(data.payee?.seed ?? Keypair.random().secret());
109
+ if (!data.payee?.address) {
110
+ log(`creating the payee account (2 XLM of ledger rent)…`);
111
+ const acc = await vault.horizon.loadAccount(vault.address);
112
+ const tx = new TransactionBuilder(new Account(vault.address, acc.sequence), {
113
+ fee: "100", networkPassphrase: passphrase((data.network ?? "testnet")),
114
+ })
115
+ .addOperation(Operation.createAccount({ destination: kp.publicKey(), startingBalance: "2" }))
116
+ .setTimeout(120).build();
117
+ tx.sign(Keypair.fromSecret(keys.principal));
118
+ tx.sign(Keypair.fromSecret(keys.approver));
119
+ await vault.horizon.submitTransaction(tx);
120
+ }
121
+ if (!(await trusts(vault, kp.publicKey())))
122
+ await vault.trustToken(kp.publicKey(), "1000000", kp);
123
+ data.payee = { address: kp.publicKey(), seed: kp.secret() };
124
+ writeFileSync(path, JSON.stringify(data, null, 2));
125
+ chmodSync(path, 0o600);
126
+ log(`payments land in ${kp.publicKey()}`);
127
+ return kp.publicKey();
128
+ }
129
+ /**
130
+ * Change what the vault settles in. USDC only: a custom token would need an
131
+ * issuer key, and holding one quietly is not a decision to make in a flag.
132
+ * The trustline needs both human keys, so this has to run before
133
+ * `approver --forget` takes one away.
134
+ */
135
+ async function switchToken(path, data, code) {
136
+ if (code !== "USDC") {
137
+ bad(`switching to a custom token means holding an issuer key. Only USDC can be\n switched to here. Create a token vault with: paydlux setup --token ${code}`);
138
+ }
139
+ const network = (data.network ?? "testnet");
140
+ if (!data.keys.approver)
141
+ bad(`adding the USDC trustline needs both human keys and this machine holds one. Re-run on the machine with both, then use approver --forget.`);
142
+ 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);
143
+ log(`adding a USDC trustline to the vault…`);
144
+ await vault.trustUsdc(Keypair.fromSecret(data.keys.principal), Keypair.fromSecret(data.keys.approver));
145
+ data.token = { code: "USDC", issuer: USDC_ISSUER[network] };
146
+ writeFileSync(path, JSON.stringify(data, null, 2));
147
+ chmodSync(path, 0o600);
148
+ log("the vault now settles in Circle USDC");
149
+ }
63
150
  // ------------------------------------------------------------------------ sell
64
151
  async function sell() {
65
152
  const { path, data } = loadVault();
66
153
  const network = (data.network ?? "testnet");
67
154
  const keys = data.keys;
68
- const payee = data.payee?.address;
69
- if (!payee)
70
- bad(`${path} has no payee.address — where should payment land?\n add it, or re-run: paydlux setup`);
155
+ // Settle in real money instead of the token this vault issued. Needs a
156
+ // trustline, which needs both human keys, so it happens here — before
157
+ // `approver --forget` takes one of them away.
158
+ const wantToken = arg("token")?.toUpperCase();
159
+ if (wantToken && wantToken !== data.token.code)
160
+ await switchToken(path, data, wantToken);
71
161
  const price = arg("price") ?? data.policy?.defaultPrice ?? "1";
72
162
  if (!(Number(price) > 0))
73
163
  bad("--price must be a positive number");
@@ -82,9 +172,26 @@ async function sell() {
82
172
  const maxPer = Number(arg("max") ?? data.policy?.maxPerPayment ?? 2);
83
173
  const daily = Number(arg("daily") ?? data.policy?.dailyBudget ?? 10);
84
174
  const policy = new PolicyEngine({ maxPerPayment: maxPer, dailyBudget: daily });
85
- 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);
175
+ // A ceiling below the price blocks every payment, and the failure looks like
176
+ // a bug in the desk: the buyer waits, the human never sees an approvable
177
+ // item, and the log says "mandate refused". Say it now, before anything is
178
+ // started, because this is a mistake in the flags and not in the product.
179
+ const priceNum = Number(price);
180
+ if (!(priceNum > 0))
181
+ bad(`--price ${price} is not a number greater than zero.`);
182
+ if (priceNum > maxPer) {
183
+ bad(`--price ${price} is above --max ${maxPer}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
184
+ }
185
+ if (priceNum > daily) {
186
+ bad(`--price ${price} is above --daily ${daily}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
187
+ }
188
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: keys.approver ? pub(keys.approver) : undefined, agent: pub(keys.agent) }, network);
86
189
  if (data.token.code === "USDC")
87
190
  vault.useUsdc();
191
+ // Resolve where the money lands before anything starts listening. A parked
192
+ // payment with no valid payee is a payment that fails an hour later, after
193
+ // the human already said yes.
194
+ const payee = await ensurePayee(path, data, vault);
88
195
  if (network === "public") {
89
196
  console.log(`
90
197
  MAINNET — real money.
@@ -123,6 +230,8 @@ async function sell() {
123
230
  publicHost,
124
231
  agentKeys: [pub(keys.agent)],
125
232
  routes: [{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600) }],
233
+ // The banner prints these numbers. This line is what makes them true.
234
+ policy,
126
235
  });
127
236
  let tunnel = null;
128
237
  if (tls) {
@@ -186,7 +295,7 @@ async function vaultRent(vault) {
186
295
  async function status() {
187
296
  const { data } = loadVault();
188
297
  const network = (data.network ?? "testnet");
189
- 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);
298
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(data.keys.principal), approver: data.keys.approver ? pub(data.keys.approver) : undefined, agent: pub(data.keys.agent) }, network);
190
299
  if (data.token.code === "USDC")
191
300
  vault.useUsdc();
192
301
  const rent = await vaultRent(vault);
@@ -407,6 +516,49 @@ removed from ${path}. this machine can no longer approve anything by itself.
407
516
  sell with: paydlux sell --split --tls
408
517
  `);
409
518
  }
519
+ // --------------------------------------------------------------------- withdraw
520
+ /**
521
+ * Take your money back out of the vault. This is a normal 2-of-2 payment:
522
+ * principal (this laptop) + approver (your phone, or the laptop until you run
523
+ * `approver --forget`). The agent key cannot do this — it has no path to an
524
+ * arbitrary destination, which is the whole point of the vault.
525
+ *
526
+ * The XLM the vault pays in ledger rent stays locked while the account exists.
527
+ * Closing the vault (not done here) returns that too.
528
+ */
529
+ async function withdraw() {
530
+ const { data } = loadVault();
531
+ const to = arg("to");
532
+ const amount = arg("amount");
533
+ const all = flag("all");
534
+ if (!to)
535
+ bad(`where should it go?\n\n paydlux withdraw --to G… [--all | --amount 8]\n\n --to can be any Stellar account, including your Binance deposit address.`);
536
+ const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, {
537
+ principal: pub(data.keys.principal),
538
+ approver: data.keys.approver ? pub(data.keys.approver) : undefined,
539
+ agent: pub(data.keys.agent),
540
+ }, (data.network ?? "testnet"));
541
+ if (data.token.code === "USDC")
542
+ vault.useUsdc();
543
+ const bal = await vault.balance();
544
+ const want = all ? bal : amount;
545
+ if (!want)
546
+ bad(`say how much: --all, or --amount 8 (vault holds ${bal} ${data.token.code})`);
547
+ if (Number(want) > Number(bal))
548
+ bad(`the vault holds ${bal} ${data.token.code}, not ${want}.`);
549
+ const keys = data.keys ?? {};
550
+ const principal = Keypair.fromSecret(keys.principal);
551
+ const approver = keys.approver ? Keypair.fromSecret(keys.approver) : null;
552
+ if (!approver) {
553
+ console.log(`this laptop holds only one of the two keys, so the withdrawal needs your phone.
554
+ open: ${flag("tls") ? `https://${PUBLIC_HOST}` : "http://127.0.0.1:4022"}/?k=${data.desk?.portalKey ?? "…"}`);
555
+ bad("then run this again — approvals from the phone land automatically.");
556
+ }
557
+ console.log(`sending ${want} ${data.token.code} from the vault to ${to} …`);
558
+ const res = await vault.release(to, String(want), principal, approver, "paydlux withdraw");
559
+ console.log(`done. tx ${res?.hash}`);
560
+ console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
561
+ }
410
562
  // ------------------------------------------------------------------------- main
411
563
  const HELP = `paydlux — get paid by AI agents, with a human in the loop.
412
564
 
@@ -419,6 +571,9 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
419
571
  paydlux approver [--forget]
420
572
  print the human half of the vault, then optionally delete it from this
421
573
  machine so only the phone can approve.
574
+ paydlux withdraw --to G… [--all | --amount N]
575
+ take your money back out of the vault. Needs both human keys; the agent
576
+ key can never do this.
422
577
 
423
578
  one file holds it all: ./paydlux-vault.json (0600, never commit it)
424
579
  `;
@@ -436,6 +591,8 @@ export async function main() {
436
591
  return status();
437
592
  if (cmd === "approver")
438
593
  return approver();
594
+ if (cmd === "withdraw")
595
+ return withdraw();
439
596
  bad(`unknown command "${cmd}".\n\n${HELP}`);
440
597
  }
441
598
  // Run only when this file is the entry point, so importing it in a test is safe.
package/dist/desk.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import type { StellarVault } from "./vault.ts";
3
+ import type { PolicyEngine } from "./policy.ts";
3
4
  import { type ApprovalChallenge } from "./phone.ts";
4
5
  /** A portal link is a capability: whoever holds it can approve. So it is long,
5
6
  * random, and compared without leaking timing. */
@@ -20,6 +21,13 @@ export type DeskRecord = {
20
21
  * twice and the buyer never gets the content they already bought.
21
22
  */
22
23
  key?: string;
24
+ /**
25
+ * The failure came from the seller's spending mandate, not from the ledger.
26
+ * The gateway needs this distinction: a ledger failure is worth retrying, a
27
+ * mandate refusal never changes, so re-parking the same request would loop
28
+ * forever — refuse, and make the buyer change the request to ask again.
29
+ */
30
+ mandateBlocked?: boolean;
23
31
  /** split mode: what the phone is asked to sign, and what it sent back */
24
32
  challenge?: ApprovalChallenge;
25
33
  qr?: string;
@@ -53,6 +61,18 @@ export type DeskConfig = {
53
61
  * gives you. Pinning the Host means the only way in is through the tunnel.
54
62
  */
55
63
  publicHost?: string;
64
+ /**
65
+ * The spending mandate. Optional only so existing tests keep compiling; every
66
+ * real seller passes one.
67
+ *
68
+ * Enforced HERE, at the moment money moves, not in the gateway. The gateway is
69
+ * the buyer's surface: it can be restarted, bypassed, or run by someone who
70
+ * forgot to configure it. The desk is the one place every payment passes
71
+ * through on its way to the ledger — dev mode signs and submits here, split
72
+ * mode submits the phone's signature here — so this is the only place a
73
+ * ceiling can actually hold.
74
+ */
75
+ policy?: PolicyEngine;
56
76
  };
57
77
  export declare class ApprovalDesk {
58
78
  private records;
@@ -75,6 +95,20 @@ export declare class ApprovalDesk {
75
95
  * principal only, and hand it to the phone. The money moves when
76
96
  * /signed comes back with the phone's signature.
77
97
  */
98
+ /**
99
+ * The mandate check, in one place, called before money moves.
100
+ *
101
+ * Returns null when the payment is allowed, or the reason it is not. The
102
+ * record is marked failed so the buyer's poll gets a real answer instead of a
103
+ * timeout, and the reason is surfaced to the human rather than logged only.
104
+ *
105
+ * Why this exists at all: `--max` and `--daily` were printed in the seller's
106
+ * banner and never applied to anything. A ceiling nobody enforces is worse
107
+ * than no ceiling, because the seller believes they have one.
108
+ */
109
+ private mandateBlock;
110
+ /** The mandate has been satisfied: count the money as spent. */
111
+ private recordSpend;
78
112
  approve(id: string): Promise<DeskRecord>;
79
113
  /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
80
114
  private requestPhoneSignature;
@@ -85,7 +119,23 @@ export declare class ApprovalDesk {
85
119
  * ledger rejects it if the signature is not over this exact transaction.
86
120
  */
87
121
  complete(id: string, signedXdr: string): Promise<DeskRecord>;
122
+ /**
123
+ * The human said no.
124
+ *
125
+ * `awaiting_phone` is a valid state to deny from. Opening the phone link
126
+ * builds the challenge on the spot (so a human needs one tap, not two), which
127
+ * means the first thing a person sees on their phone is already
128
+ * `awaiting_phone` — refusing to deny from that state left the Reject button
129
+ * dead, which was verified on live testnet. Denying is always allowed up to
130
+ * the point the transaction is submitted; after that it is too late.
131
+ */
88
132
  deny(id: string, reason?: string): DeskRecord;
133
+ /**
134
+ * What the phone app is allowed to see. Never the paid `content` — that is
135
+ * what the buyer has not paid for yet — and never the raw challenge XDR,
136
+ * which the app fetches per item so the desk can re-check the binding.
137
+ */
138
+ queue(): Record<string, unknown>[];
89
139
  /** Serve the pending list as JSON (the portal polls this). */
90
140
  api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
91
141
  listen(port?: number): Promise<void>;