@paydlux/agent 0.1.10 → 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/dist/agent.d.ts +8 -0
- package/dist/agent.js +1 -1
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +117 -8
- package/dist/desk.d.ts +34 -0
- package/dist/desk.js +97 -12
- package/dist/gateway.d.ts +10 -2
- package/dist/gateway.js +20 -1
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +2 -2
- package/package.json +6 -2
- package/phone-app/app.js +264 -14
- package/phone-app/index.html +134 -55
- package/phone-app/manifest.webmanifest +5 -1
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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);
|
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;
|
package/dist/desk.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// - a stolen phone cannot approve alone
|
|
11
11
|
// - the approval is a real ledger signature, not a database flag
|
|
12
12
|
import { createServer } from "node:http";
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
@@ -27,31 +27,51 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
27
27
|
const APP_DIR = join(HERE, "..", "phone-app");
|
|
28
28
|
const PORTAL_DIR = join(HERE, "..", "portal-app");
|
|
29
29
|
const staticCache = new Map();
|
|
30
|
+
// mtime of the bytes held in staticCache, so an edited file is picked up on the
|
|
31
|
+
// next request instead of after a restart. See readStatic.
|
|
32
|
+
const staticStamp = new Map();
|
|
30
33
|
/**
|
|
31
|
-
* Static files are read
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
+
* Static files are read from disk, not bundled, so the desk and its front ends
|
|
35
|
+
* can never drift apart. The name is stripped to [a-z._-] before it touches a
|
|
36
|
+
* path, so a request can't walk out of the directory.
|
|
34
37
|
*
|
|
35
38
|
* PNGs are returned as bytes: reading an image as utf8 text replaces every
|
|
36
39
|
* invalid sequence with U+FFFD and ships a corrupt icon to the phone.
|
|
40
|
+
*
|
|
41
|
+
* The cache is keyed on the file's mtime, not just its name. Caching once per
|
|
42
|
+
* process looked fine and was not: the approver's service worker is deliberately
|
|
43
|
+
* network-first so that a security fix reaches an installed phone on the next
|
|
44
|
+
* open, and a desk that holds the old bytes in memory until it restarts breaks
|
|
45
|
+
* exactly that promise. A patched app.js on disk would have sat invisible for
|
|
46
|
+
* days behind a running desk. One stat() per request is cheap; a stale approver
|
|
47
|
+
* page that keeps signing old-style transactions is not.
|
|
37
48
|
*/
|
|
38
49
|
function readStatic(dir, name, label) {
|
|
39
50
|
const safe = name.replace(/[^a-z0-9._-]/g, "");
|
|
40
|
-
const
|
|
41
|
-
|
|
51
|
+
const file = join(dir, safe);
|
|
52
|
+
let stamp;
|
|
53
|
+
try {
|
|
54
|
+
stamp = statSync(file).mtimeMs;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null; // missing file: a 404, not a crash
|
|
58
|
+
}
|
|
59
|
+
const key = dir + "/" + safe;
|
|
60
|
+
const hit = staticCache.get(key);
|
|
61
|
+
if (hit && staticStamp.get(key) === stamp)
|
|
42
62
|
return hit;
|
|
43
63
|
let text;
|
|
44
64
|
try {
|
|
45
|
-
text = safe.endsWith(".png")
|
|
46
|
-
? readFileSync(join(dir, safe))
|
|
47
|
-
: readFileSync(join(dir, safe), "utf8");
|
|
65
|
+
text = safe.endsWith(".png") ? readFileSync(file) : readFileSync(file, "utf8");
|
|
48
66
|
}
|
|
49
67
|
catch {
|
|
50
|
-
//
|
|
51
|
-
//
|
|
68
|
+
// Unreadable between the stat and the read, or not a file at all. The
|
|
69
|
+
// request handler is not a place to raise: one bad URL must not take the
|
|
70
|
+
// whole desk down.
|
|
52
71
|
return null;
|
|
53
72
|
}
|
|
54
|
-
staticCache.set(
|
|
73
|
+
staticCache.set(key, text);
|
|
74
|
+
staticStamp.set(key, stamp);
|
|
55
75
|
return text;
|
|
56
76
|
}
|
|
57
77
|
const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
|
|
@@ -174,12 +194,61 @@ export class ApprovalDesk {
|
|
|
174
194
|
* principal only, and hand it to the phone. The money moves when
|
|
175
195
|
* /signed comes back with the phone's signature.
|
|
176
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* The mandate check, in one place, called before money moves.
|
|
199
|
+
*
|
|
200
|
+
* Returns null when the payment is allowed, or the reason it is not. The
|
|
201
|
+
* record is marked failed so the buyer's poll gets a real answer instead of a
|
|
202
|
+
* timeout, and the reason is surfaced to the human rather than logged only.
|
|
203
|
+
*
|
|
204
|
+
* Why this exists at all: `--max` and `--daily` were printed in the seller's
|
|
205
|
+
* banner and never applied to anything. A ceiling nobody enforces is worse
|
|
206
|
+
* than no ceiling, because the seller believes they have one.
|
|
207
|
+
*/
|
|
208
|
+
mandateBlock(rec, when) {
|
|
209
|
+
const policy = this.cfg.policy;
|
|
210
|
+
if (!policy)
|
|
211
|
+
return null;
|
|
212
|
+
// Approvals already made that have not settled yet. Those are promises the
|
|
213
|
+
// seller has already given, so a new one has to fit alongside them.
|
|
214
|
+
// `pending` deliberately does NOT count: that is a request nobody has
|
|
215
|
+
// approved, and counting the whole queue would refuse the first approval
|
|
216
|
+
// just because more requests exist than the budget can cover.
|
|
217
|
+
const committed = this.list().reduce((sum, r) => (r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"))
|
|
218
|
+
? sum + Number(r.price) : sum, 0);
|
|
219
|
+
const verdict = policy.check({
|
|
220
|
+
amount: Number(rec.price),
|
|
221
|
+
payee: this.cfg.payee,
|
|
222
|
+
ttl: this.cfg.decideWithinSeconds,
|
|
223
|
+
committed,
|
|
224
|
+
});
|
|
225
|
+
if (verdict.ok)
|
|
226
|
+
return null;
|
|
227
|
+
rec.status = "failed";
|
|
228
|
+
rec.reason = `mandate refused (${verdict.rule}): ${verdict.detail}`;
|
|
229
|
+
rec.mandateBlocked = true;
|
|
230
|
+
rec.challenge = undefined;
|
|
231
|
+
rec.signedXdr = undefined;
|
|
232
|
+
console.log(`[desk] ${rec.id} ${when}: ${rec.reason}`);
|
|
233
|
+
return rec.reason;
|
|
234
|
+
}
|
|
235
|
+
/** The mandate has been satisfied: count the money as spent. */
|
|
236
|
+
recordSpend(rec, tx) {
|
|
237
|
+
this.cfg.policy?.record({
|
|
238
|
+
at: Date.now(), amount: Number(rec.price), payee: this.cfg.payee,
|
|
239
|
+
uri: rec.uri, mandateHash: tx,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
177
242
|
async approve(id) {
|
|
178
243
|
const rec = this.records.get(id);
|
|
179
244
|
if (!rec)
|
|
180
245
|
throw new Error("unknown handle " + id);
|
|
181
246
|
if (rec.status !== "pending")
|
|
182
247
|
throw new Error(`already ${rec.status}`);
|
|
248
|
+
// Check before asking the phone. A human who approves a payment the mandate
|
|
249
|
+
// will refuse has spent a Face ID scan and a signature for nothing.
|
|
250
|
+
if (this.mandateBlock(rec, "refused before asking"))
|
|
251
|
+
return rec;
|
|
183
252
|
if (this.cfg.split)
|
|
184
253
|
return this.requestPhoneSignature(rec);
|
|
185
254
|
if (!this.cfg.principal || !this.cfg.approver) {
|
|
@@ -191,6 +260,7 @@ export class ApprovalDesk {
|
|
|
191
260
|
const res = await this.cfg.vault.release(this.cfg.payee, rec.price, Keypair.fromSecret(this.cfg.principal), Keypair.fromSecret(this.cfg.approver), id);
|
|
192
261
|
rec.status = "settled";
|
|
193
262
|
rec.tx = String(res?.hash ?? "");
|
|
263
|
+
this.recordSpend(rec, rec.tx);
|
|
194
264
|
}
|
|
195
265
|
catch (e) {
|
|
196
266
|
rec.status = "failed";
|
|
@@ -255,15 +325,25 @@ export class ApprovalDesk {
|
|
|
255
325
|
console.log(`[desk] ${id} rejected: ${problem}`);
|
|
256
326
|
return rec;
|
|
257
327
|
}
|
|
328
|
+
// Re-check at the last possible moment. The budget was verified when the
|
|
329
|
+
// phone was asked; a day is long and other payments have been approved since.
|
|
330
|
+
// Better to refuse now than to submit a payment the mandate forbids.
|
|
331
|
+
if (this.mandateBlock(rec, "refused at submission"))
|
|
332
|
+
return rec;
|
|
258
333
|
rec.signedXdr = signedXdr;
|
|
259
334
|
try {
|
|
260
335
|
const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
|
|
261
336
|
rec.status = "settled";
|
|
262
337
|
rec.tx = res.hash;
|
|
338
|
+
this.recordSpend(rec, res.hash);
|
|
339
|
+
// The money moving is the one event a seller should never have to
|
|
340
|
+
// discover by refreshing a page.
|
|
341
|
+
console.log(`[desk] ${rec.id} PAID ${rec.price} ${this.cfg.vault.token.getCode()} — ledger ${rec.tx}`);
|
|
263
342
|
}
|
|
264
343
|
catch (e) {
|
|
265
344
|
rec.status = "failed";
|
|
266
345
|
rec.reason = e.message;
|
|
346
|
+
console.log(`[desk] ${rec.id} failed on the ledger: ${rec.reason}`);
|
|
267
347
|
}
|
|
268
348
|
return rec;
|
|
269
349
|
}
|
|
@@ -500,6 +580,11 @@ export class ApprovalDesk {
|
|
|
500
580
|
return send(200, {
|
|
501
581
|
id: rec.id, network: rec.challenge.network,
|
|
502
582
|
hash: rec.challenge.hash, xdr: rec.challenge.xdr,
|
|
583
|
+
// Which key this vault will actually accept. A phone holding a key
|
|
584
|
+
// from a previous run signs a valid signature over a valid
|
|
585
|
+
// transaction, and the ledger answers BAD_AUTH with no explanation.
|
|
586
|
+
// Naming the expected key here lets the app say so BEFORE signing.
|
|
587
|
+
approver: this.cfg.vault.signers.approver,
|
|
503
588
|
// what the human is being asked to read. Agent-controlled text: the
|
|
504
589
|
// app renders it as text, never as markup.
|
|
505
590
|
wants: (rec.body ?? "").slice(0, 200),
|
package/dist/gateway.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
2
|
import { Transaction } from "@stellar/stellar-sdk";
|
|
3
3
|
import { StellarVault, StellarTxError, type Amount, type Mandate, type Network } from "./vault.ts";
|
|
4
4
|
import { ApprovalDesk, type DeskStore } from "./desk.ts";
|
|
5
|
+
import type { PolicyEngine } from "./policy.ts";
|
|
5
6
|
export declare const PAY_HEADER = "x-payment";
|
|
6
7
|
export type Route = {
|
|
7
8
|
uri: string;
|
|
@@ -37,6 +38,8 @@ export type GatewayConfig = {
|
|
|
37
38
|
portalKey?: string;
|
|
38
39
|
/** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
|
|
39
40
|
publicHost?: string;
|
|
41
|
+
/** how long a human gets to decide before the parked payment's tx is dead */
|
|
42
|
+
decideWithinSeconds?: number;
|
|
40
43
|
/**
|
|
41
44
|
* Agent public keys allowed to park a payment for approval. An intent is only
|
|
42
45
|
* honoured if it is signed by one of these, so an unlisted key gets a plain
|
|
@@ -50,6 +53,11 @@ export type GatewayConfig = {
|
|
|
50
53
|
* loopback address, which is only useful on one machine.
|
|
51
54
|
*/
|
|
52
55
|
deskUrl?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The spending mandate. Passed straight to the desk, which is where it is
|
|
58
|
+
* enforced — see DeskConfig.policy for why the gateway is the wrong place.
|
|
59
|
+
*/
|
|
60
|
+
policy?: PolicyEngine;
|
|
53
61
|
};
|
|
54
62
|
export type PaymentProof = {
|
|
55
63
|
/** pre-authorised envelope, base64 XDR, as issued by the vault */
|
|
@@ -111,7 +119,7 @@ export declare class Gateway {
|
|
|
111
119
|
hash: string;
|
|
112
120
|
}>;
|
|
113
121
|
handler(getContent: (uri: string) => string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
114
|
-
listen(port: number, getContent?: (uri: string) => string): Promise<
|
|
122
|
+
listen(port: number, getContent?: (uri: string) => string): Promise<Server<typeof IncomingMessage, typeof ServerResponse>>;
|
|
115
123
|
}
|
|
116
124
|
export { StellarTxError };
|
|
117
125
|
export type { DeskStore };
|
package/dist/gateway.js
CHANGED
|
@@ -39,6 +39,8 @@ export class Gateway {
|
|
|
39
39
|
portalKey: cfg.portalKey,
|
|
40
40
|
split: cfg.split,
|
|
41
41
|
publicHost: cfg.publicHost,
|
|
42
|
+
decideWithinSeconds: cfg.decideWithinSeconds,
|
|
43
|
+
policy: cfg.policy,
|
|
42
44
|
});
|
|
43
45
|
}
|
|
44
46
|
/** Mandates the vault currently holds, so the gateway can hand them out. */
|
|
@@ -244,6 +246,20 @@ export class Gateway {
|
|
|
244
246
|
return;
|
|
245
247
|
}
|
|
246
248
|
}
|
|
249
|
+
// The seller's own spending mandate refused this one. Retrying the
|
|
250
|
+
// identical request can never succeed — the ceiling is not going to
|
|
251
|
+
// move — so re-parking it would loop: park, refuse, park, refuse, with
|
|
252
|
+
// a fresh handle each time and a queue the human cannot clear. Answer
|
|
253
|
+
// 402 and make the buyer change the request (a smaller price, a
|
|
254
|
+
// different route) to ask again.
|
|
255
|
+
if (hit.mandateBlocked) {
|
|
256
|
+
res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
|
|
257
|
+
res.end(JSON.stringify({
|
|
258
|
+
error: "mandate_refused", handle: hit.id,
|
|
259
|
+
reason: hit.reason ?? "refused by the seller's spending mandate",
|
|
260
|
+
}));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
247
263
|
// A human said no. Do not silently re-open the same request and nag
|
|
248
264
|
// them with it: the buyer has to change the request to ask again.
|
|
249
265
|
if (hit.status === "denied") {
|
|
@@ -332,7 +348,10 @@ export class Gateway {
|
|
|
332
348
|
}
|
|
333
349
|
listen(port, getContent = (u) => `payload for ${u} at ${new Date().toISOString()}`) {
|
|
334
350
|
const srv = createServer(this.handler(getContent));
|
|
335
|
-
|
|
351
|
+
// Hand the server back. Returning void meant a started gateway could never
|
|
352
|
+
// be stopped, which left tests holding ports and a seller with no clean way
|
|
353
|
+
// to change a route without killing the process.
|
|
354
|
+
return new Promise((resolve) => srv.listen(port, () => resolve(srv)));
|
|
336
355
|
}
|
|
337
356
|
}
|
|
338
357
|
function readBody(req) {
|
package/dist/policy.d.ts
CHANGED
|
@@ -48,5 +48,14 @@ export declare class PolicyEngine {
|
|
|
48
48
|
payee: string;
|
|
49
49
|
ttl?: number;
|
|
50
50
|
liveMandates?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Money not spent yet but already promised: payments a human has approved
|
|
53
|
+
* and is waiting on a signature for. Without this the ceiling only counts
|
|
54
|
+
* settled payments, so a seller with a 20/day budget can approve six 4.50
|
|
55
|
+
* requests, spend six Face ID scans, and watch two of them fail at
|
|
56
|
+
* submission. Counting the queue refuses the fifth one at the button
|
|
57
|
+
* instead, which is the point of having a human in the loop.
|
|
58
|
+
*/
|
|
59
|
+
committed?: number;
|
|
51
60
|
}): Verdict;
|
|
52
61
|
}
|
package/dist/policy.js
CHANGED
|
@@ -36,9 +36,9 @@ export class PolicyEngine {
|
|
|
36
36
|
if (req.amount > p.maxPerPayment) {
|
|
37
37
|
return no("max_per_payment", `${req.amount} exceeds the per-payment ceiling of ${p.maxPerPayment}`);
|
|
38
38
|
}
|
|
39
|
-
const spent = this.spentToday();
|
|
39
|
+
const spent = this.spentToday() + (req.committed ?? 0);
|
|
40
40
|
if (spent + req.amount > p.dailyBudget) {
|
|
41
|
-
return no("daily_budget", `${req.amount} would take today to ${(spent + req.amount).toFixed(2)}, over the ${p.dailyBudget} budget (
|
|
41
|
+
return no("daily_budget", `${req.amount} would take today to ${(spent + req.amount).toFixed(2)}, over the ${p.dailyBudget} budget (${this.spentToday().toFixed(2)} paid${req.committed ? ` + ${req.committed.toFixed(2)} already approved and waiting` : ""})`);
|
|
42
42
|
}
|
|
43
43
|
if (p.denylist?.includes(req.payee))
|
|
44
44
|
return no("denylist", `${short(req.payee)} is blocked`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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",
|
|
@@ -40,7 +40,11 @@
|
|
|
40
40
|
"test": "cd contracts && forge test",
|
|
41
41
|
"test:stellar": "tsx stellar/demo.ts",
|
|
42
42
|
"test:portal": "tsx stellar/xss-check.ts",
|
|
43
|
-
"test:
|
|
43
|
+
"test:mandate": "tsx stellar/mandate-check.ts",
|
|
44
|
+
"test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/mandate-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 && tsx ios/bridge-check.ts",
|
|
45
|
+
"test:ios": "tsx ios/bridge-check.ts",
|
|
46
|
+
"test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
|
|
47
|
+
"test:seed": "DEVELOPER_DIR=/Library/Developer/CommandLineTools /usr/bin/swiftc -target arm64-apple-macosx14.0 ios/PaydluxApprover/Seed.swift ios/PaydluxApprover/Hex.swift ios/Checks/main.swift -o \"$TMPDIR/seedcheck\" && \"$TMPDIR/seedcheck\"",
|
|
44
48
|
"typecheck": "tsc --noEmit",
|
|
45
49
|
"app": "tsx stellar/cli.ts",
|
|
46
50
|
"app:mainnet": "tsx stellar/cli.ts setup --mainnet"
|