@paydlux/agent 0.1.22 → 0.1.24
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 +80 -0
- package/dist/cli.js +344 -16
- package/dist/desk.d.ts +51 -1
- package/dist/desk.js +240 -30
- package/dist/gateway.d.ts +6 -1
- package/dist/gateway.js +1 -0
- package/dist/mcp.d.ts +24 -0
- package/dist/mcp.js +387 -0
- package/dist/policy.d.ts +51 -1
- package/dist/policy.js +102 -3
- package/dist/portal.d.ts +17 -0
- package/dist/portal.js +97 -1
- package/dist/showcase.d.ts +14 -0
- package/dist/showcase.js +99 -4
- package/dist/vault.d.ts +13 -0
- package/dist/vault.js +22 -0
- package/package.json +19 -4
- package/portal-app/portal.css +22 -0
- package/portal-app/rules.js +62 -0
package/dist/portal.js
CHANGED
|
@@ -19,12 +19,108 @@ export function portalPage(records, vault, network, qs = "") {
|
|
|
19
19
|
<title>Paydlux — approvals</title>
|
|
20
20
|
<link rel="stylesheet" href="portal.css"></head><body>
|
|
21
21
|
<h1>Paydlux — approvals</h1>
|
|
22
|
-
<div class="sub"
|
|
22
|
+
<div class="sub"><a class="nav" href="/${qs}">approvals</a> · <a class="nav" href="/rules${qs}">spending rules</a><br>
|
|
23
|
+
vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
|
|
23
24
|
<table><thead><tr><th>when</th><th>what</th><th>cost</th><th>status</th><th></th></tr></thead>
|
|
24
25
|
<tbody>${rows}</tbody></table>
|
|
25
26
|
<div class="foot">Approving signs a Stellar transaction with your key. The vault needs two signatures, so this device alone cannot release funds.</div>
|
|
26
27
|
<script src="portal.js"></script></body></html>`;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The spending rules, as a form the owner can read and edit.
|
|
31
|
+
*
|
|
32
|
+
* This is the surface the "customizable approval policy" request is really
|
|
33
|
+
* about: limits, approved wallets, and categories in one place, instead of
|
|
34
|
+
* command-line flags nobody outside a terminal can see.
|
|
35
|
+
*
|
|
36
|
+
* What it does NOT do is approve anything. Every payment still needs a human
|
|
37
|
+
* signature; these rules only decide what a human gets asked to sign. A rule
|
|
38
|
+
* set here can narrow the agent's reach, never widen what the ledger will
|
|
39
|
+
* accept, and the page says so in the same words the code enforces.
|
|
40
|
+
*/
|
|
41
|
+
export function rulesPage(policy, spent, vault, network, qs = "", saved = false) {
|
|
42
|
+
const cats = Object.entries(policy.categories ?? {});
|
|
43
|
+
const catRows = cats.length
|
|
44
|
+
? cats.map(([name, rule]) => categoryRow(name, rule, policy, spent.byCategory[name] ?? 0)).join("\n")
|
|
45
|
+
: `<tr><td colspan="5" class="empty">no categories — every seller shares the daily budget</td></tr>`;
|
|
46
|
+
const mapped = Object.entries(policy.payeeCategory ?? {});
|
|
47
|
+
return `<!doctype html><html><head>
|
|
48
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
49
|
+
<title>Paydlux — spending rules</title>
|
|
50
|
+
<link rel="stylesheet" href="portal.css"></head><body>
|
|
51
|
+
<h1>Paydlux — spending rules</h1>
|
|
52
|
+
<div class="sub"><a class="nav" href="/${qs}">approvals</a> · <a class="nav" href="/rules${qs}">spending rules</a><br>
|
|
53
|
+
vault ${esc(vault)} · ${esc(network)}${saved ? ` · <span class="saved">saved</span>` : ""}</div>
|
|
54
|
+
|
|
55
|
+
<form id="rules" method="post" action="/rules${qs}">
|
|
56
|
+
<h2>Every payment</h2>
|
|
57
|
+
<div class="grid">
|
|
58
|
+
<label>Most for one payment
|
|
59
|
+
<input name="maxPerPayment" type="number" min="0.0000001" step="0.0000001" value="${esc(policy.maxPerPayment)}"></label>
|
|
60
|
+
<label>Daily budget
|
|
61
|
+
<input name="dailyBudget" type="number" min="0.0000001" step="0.0000001" value="${esc(policy.dailyBudget)}"></label>
|
|
62
|
+
</div>
|
|
63
|
+
<p class="note">Spent today: <b>${esc(spent.total.toFixed(7))}</b> of ${esc(policy.dailyBudget)}. The day resets at 00:00 UTC.</p>
|
|
64
|
+
|
|
65
|
+
<h2>Wallets the agent may pay</h2>
|
|
66
|
+
<div class="grid">
|
|
67
|
+
<label>Allowed (one account per line, blank = any)
|
|
68
|
+
<textarea name="allowlist" rows="3" spellcheck="false" autocomplete="off">${esc((policy.allowlist ?? []).join("\n"))}</textarea></label>
|
|
69
|
+
<label>Never allowed, even if listed above
|
|
70
|
+
<textarea name="denylist" rows="3" spellcheck="false" autocomplete="off">${esc((policy.denylist ?? []).join("\n"))}</textarea></label>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<h2>Categories</h2>
|
|
74
|
+
<p class="note">Split the daily budget by what the money is for. A category can only be
|
|
75
|
+
tighter than the limits above — it can never raise them.</p>
|
|
76
|
+
<table class="cats"><thead><tr><th>name</th><th>max / payment</th><th>daily</th><th>spent today</th><th></th></tr></thead>
|
|
77
|
+
<tbody id="catbody">${catRows}</tbody></table>
|
|
78
|
+
<div class="addcat">
|
|
79
|
+
<input name="newcat" placeholder="name (a-z, 0-9)" maxlength="32" spellcheck="false" autocomplete="off">
|
|
80
|
+
<input name="newcat_max" type="number" min="0.0000001" step="0.0000001" placeholder="max / payment">
|
|
81
|
+
<input name="newcat_daily" type="number" min="0.0000001" step="0.0000001" placeholder="daily">
|
|
82
|
+
<button class="yes" type="submit" name="action" value="add">Add category</button>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<h2>Which wallet is in which category</h2>
|
|
86
|
+
${mapped.length
|
|
87
|
+
? `<table class="map"><thead><tr><th>wallet</th><th>category</th></tr></thead><tbody>
|
|
88
|
+
${mapped.map(([payee, name]) => `<tr><td class="tx">${esc(payee)}</td><td>${esc(name)}`
|
|
89
|
+
+ ` <button class="no mini" data-del="${esc(payee)}" data-map="1">Remove</button></td></tr>`).join("\n")}
|
|
90
|
+
</tbody></table>`
|
|
91
|
+
: `<p class="note">Nothing categorised yet. Add a wallet below to cap it on its own.</p>`}
|
|
92
|
+
<div class="addcat">
|
|
93
|
+
<input name="newpayee" placeholder="G… wallet address" maxlength="56" spellcheck="false" autocomplete="off">
|
|
94
|
+
<input name="newpayee_cat" placeholder="category" maxlength="32" spellcheck="false" autocomplete="off">
|
|
95
|
+
<button class="yes" type="submit" name="action" value="map">Add</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="save">
|
|
99
|
+
<button class="yes" type="submit" name="action" value="save">Save rules</button>
|
|
100
|
+
</div>
|
|
101
|
+
</form>
|
|
102
|
+
|
|
103
|
+
<div class="foot">These rules decide what you are <i>asked</i> to approve. They never approve
|
|
104
|
+
anything themselves: every payment still needs your signature, and the vault needs two.</div>
|
|
105
|
+
<script src="rules.js"></script></body></html>`;
|
|
106
|
+
}
|
|
107
|
+
function categoryRow(name, rule, policy, spent) {
|
|
108
|
+
// Names go in both a hidden value and the delete button's data-*, so they are
|
|
109
|
+
// escaped like any other attacker-chosen string — even though update() already
|
|
110
|
+
// reduced them to [a-z0-9_-].
|
|
111
|
+
//
|
|
112
|
+
// The name is deliberately NOT an editable input. Renaming a bucket would
|
|
113
|
+
// orphan every wallet mapped to it, and an orphaned wallet stops having a
|
|
114
|
+
// category ceiling — a rename would silently loosen the rules while looking
|
|
115
|
+
// like a cosmetic edit. Renaming is therefore remove + add, which forces the
|
|
116
|
+
// human to re-map the wallets and see what they are doing.
|
|
117
|
+
return `<tr>
|
|
118
|
+
<td><span class="catname">${esc(name)}</span><input type="hidden" name="cat_${esc(name)}_name" value="${esc(name)}"></td>
|
|
119
|
+
<td><input name="cat_${esc(name)}_max" type="number" min="0.0000001" step="0.0000001" value="${esc(rule.maxPerPayment ?? "")}" placeholder="${esc(policy.maxPerPayment)}"></td>
|
|
120
|
+
<td><input name="cat_${esc(name)}_daily" type="number" min="0.0000001" step="0.0000001" value="${esc(rule.dailyBudget ?? "")}" placeholder="${esc(policy.dailyBudget)}"></td>
|
|
121
|
+
<td class="amt">${esc(spent.toFixed(7))}</td>
|
|
122
|
+
<td><button class="no" data-del="${esc(name)}">Remove</button></td></tr>`;
|
|
123
|
+
}
|
|
28
124
|
function render(r, qs = "") {
|
|
29
125
|
// qs is the portal key query string ("?k=..."). The approver link has to carry
|
|
30
126
|
// it, because the desk answers 403 for anything without it.
|
package/dist/showcase.d.ts
CHANGED
|
@@ -42,6 +42,20 @@ export interface DemoConfig {
|
|
|
42
42
|
donateHost?: string;
|
|
43
43
|
/** Called whenever a key is enrolled, so the gateway's accepted set follows. */
|
|
44
44
|
onKeys?: (pubs: string[]) => void;
|
|
45
|
+
/**
|
|
46
|
+
* The route the showcase sells (e.g. "/market/data"). /demo/last only reports
|
|
47
|
+
* settled payments for THIS route, so a seller's private sales never appear
|
|
48
|
+
* on a public page — a settled demo payment is the proof being shown, not the
|
|
49
|
+
* seller's order book.
|
|
50
|
+
*/
|
|
51
|
+
route?: string;
|
|
52
|
+
/**
|
|
53
|
+
* The address the seller gets paid at. /demo/last uses it to read the last
|
|
54
|
+
* real settlement straight off the ledger, so the proof survives a restart of
|
|
55
|
+
* the demo process — an investor visiting at 3am, with the seller asleep,
|
|
56
|
+
* still gets a hash that exists on a public explorer.
|
|
57
|
+
*/
|
|
58
|
+
payee?: string;
|
|
45
59
|
}
|
|
46
60
|
/**
|
|
47
61
|
* Serve the showcase in front of a running gateway.
|
package/dist/showcase.js
CHANGED
|
@@ -103,6 +103,43 @@ function demoPayload(cfg) {
|
|
|
103
103
|
proof: cfg.explorer,
|
|
104
104
|
}, null, 2);
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The last payment this vault really made to the seller, read off the public
|
|
108
|
+
* ledger. The in-memory desk forgets on restart; the ledger does not, so an
|
|
109
|
+
* investor who visits while the seller is asleep still reaches a hash they can
|
|
110
|
+
* open on an explorer instead of a spinner. Cached — one page load should not
|
|
111
|
+
* hammer horizon, and the answer only moves when a payment settles.
|
|
112
|
+
*/
|
|
113
|
+
let ledgerProof = null;
|
|
114
|
+
let ledgerProofAt = 0;
|
|
115
|
+
async function lastSettledOnLedger(cfg) {
|
|
116
|
+
if (!cfg.payee)
|
|
117
|
+
return null;
|
|
118
|
+
if (ledgerProof && Date.now() - ledgerProofAt < 5 * 60 * 1000)
|
|
119
|
+
return ledgerProof;
|
|
120
|
+
const host = cfg.network === "public" ? "horizon.stellar.org" : "horizon-testnet.stellar.org";
|
|
121
|
+
try {
|
|
122
|
+
const r = await fetch(`https://${host}/accounts/${cfg.vault}/payments?order=desc&limit=25`, { signal: AbortSignal.timeout(4000) });
|
|
123
|
+
if (!r.ok)
|
|
124
|
+
return null;
|
|
125
|
+
const records = ((await r.json())?._embedded?.records) ?? [];
|
|
126
|
+
for (const p of records) {
|
|
127
|
+
// horizon calls the hash `transaction_hash`; a record without one is not a
|
|
128
|
+
// proof, so it is skipped rather than printed as "undefined".
|
|
129
|
+
if (p?.to !== cfg.payee || !(Number(p.amount) > 0) || !p.transaction_hash)
|
|
130
|
+
continue;
|
|
131
|
+
ledgerProof = {
|
|
132
|
+
at: Date.parse(p.created_at) || 0,
|
|
133
|
+
price: String(p.amount),
|
|
134
|
+
tx: String(p.transaction_hash),
|
|
135
|
+
};
|
|
136
|
+
ledgerProofAt = Date.now();
|
|
137
|
+
return ledgerProof;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch { /* no ledger reachable: the page still works, just without the extra proof */ }
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
106
143
|
/**
|
|
107
144
|
* Serve the showcase in front of a running gateway.
|
|
108
145
|
*
|
|
@@ -193,10 +230,32 @@ export function startDemo(cfg) {
|
|
|
193
230
|
res.end(JSON.stringify({ ok: true, until: ok.until, vault: cfg.vault }));
|
|
194
231
|
return;
|
|
195
232
|
}
|
|
233
|
+
// A visitor watching alone cannot finish step 4 — the seller's phone is the
|
|
234
|
+
// only thing that can sign it, and the seller may be asleep. The loop is
|
|
235
|
+
// worthless as a demo if it ends at "waiting", so hand them the most recent
|
|
236
|
+
// payment this route actually settled: a hash they can open on the explorer
|
|
237
|
+
// without trusting this page. Nothing else about the payment is returned —
|
|
238
|
+
// a tx hash is already public on the ledger, the buyer and the content are
|
|
239
|
+
// not.
|
|
240
|
+
if (p === "/demo/last") {
|
|
241
|
+
const done = cfg.gateway.desk.list()
|
|
242
|
+
.filter((r) => r.status === "settled" && r.tx && (!cfg.route || r.uri === cfg.route))
|
|
243
|
+
.sort((a, b) => b.at - a.at)[0];
|
|
244
|
+
const proof = done
|
|
245
|
+
? { at: done.at, price: done.price, tx: done.tx }
|
|
246
|
+
: await lastSettledOnLedger(cfg);
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
"content-type": "application/json",
|
|
249
|
+
"cache-control": "no-store",
|
|
250
|
+
"x-content-type-options": "nosniff",
|
|
251
|
+
});
|
|
252
|
+
res.end(JSON.stringify(proof ?? { none: true }));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
196
255
|
// Everything else belongs to the gateway: the paid route, /pay/status.
|
|
197
256
|
// Only demo paths are exposed here, so pointing the tunnel at this port
|
|
198
257
|
// cannot reach a seller's private routes by guessing a URI.
|
|
199
|
-
if (!(p === DEMO_ROUTE || p === "/pay/status" || p === "/demo/terms")) {
|
|
258
|
+
if (!(p === DEMO_ROUTE || p === "/pay/status" || p === "/demo/terms" || p === "/demo/last")) {
|
|
200
259
|
res.writeHead(404, { "content-type": "application/json" });
|
|
201
260
|
res.end(JSON.stringify({ error: "not found", routes: [DEMO_ROUTE] }));
|
|
202
261
|
return;
|
|
@@ -244,6 +303,7 @@ h1{font-size:30px;margin:0 0 6px;letter-spacing:-.01em}
|
|
|
244
303
|
pre{margin:10px 0 0;padding:10px 12px;background:#0f1213;border:1px solid var(--line);border-radius:8px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word;color:#cfd6d0}
|
|
245
304
|
button{font:inherit;padding:11px 18px;border-radius:9px;border:1px solid var(--gold);background:var(--gold);color:#1a1408;font-weight:600;cursor:pointer;margin:22px 0 0}
|
|
246
305
|
button[disabled]{opacity:.45;cursor:default}
|
|
306
|
+
.note{margin:12px 0 0;color:var(--dim);font-size:14px}
|
|
247
307
|
.door{margin:30px 0 0;border:1px solid var(--line);border-left:3px solid var(--gold);background:#1a1f21;border-radius:10px;padding:18px 20px}
|
|
248
308
|
.door .q{margin:0 0 6px;font-weight:600}
|
|
249
309
|
.door .a{margin:0;color:var(--dim);font-size:15px}
|
|
@@ -275,8 +335,8 @@ on a phone, and the ledger settles it. You are about to drive that loop.</p>
|
|
|
275
335
|
<div class="step" id="s3"><h3><span class="n">3</span>Request the payment</h3>
|
|
276
336
|
<p>The browser signs the request — not the payment — and sends it. The seller parks it for a human.</p>
|
|
277
337
|
<pre id="o3">not run</pre></div>
|
|
278
|
-
<div class="step" id="s4"><h3><span class="n">4</span>A human decides</h3>
|
|
279
|
-
<p>The seller
|
|
338
|
+
<div class="step" id="s4"><h3><span class="n">4</span>A human decides — and it isn't you</h3>
|
|
339
|
+
<p>The buyer half ends at step 3. From here the <b>seller</b> decides: the parked payment appears on their phone with the amount, the payee and the deadline, and a person holds the button for 1.2 seconds. The key that signs it never leaves that device, so no code — including this page — can do it for them. The app only checks the queue while it is open on the screen, so a phone in a pocket signs nothing: if nobody is holding it, this step waits. That is the product, not a bug.</p>
|
|
280
340
|
<pre id="o4">not run</pre></div>
|
|
281
341
|
<div class="step" id="s5"><h3><span class="n">5</span>The ledger proves it</h3>
|
|
282
342
|
<p>Same request, now served. The transaction hash is public and verifiable without trusting this page.</p>
|
|
@@ -284,6 +344,9 @@ on a phone, and the ledger settles it. You are about to drive that loop.</p>
|
|
|
284
344
|
</div>
|
|
285
345
|
|
|
286
346
|
<button id="go">Run the loop</button>
|
|
347
|
+
<p class="note">You are the <b>buyer</b> here. Steps 1–3 happen in your browser. Step 4 happens on the
|
|
348
|
+
seller's phone — if nobody is holding it, this page will show you the last payment this exact loop
|
|
349
|
+
settled, with a hash you can open on the explorer.</p>
|
|
287
350
|
|
|
288
351
|
<div class="door">
|
|
289
352
|
<p class="q">That was practice. Nothing you just did cost anything.</p>
|
|
@@ -312,6 +375,8 @@ function demoScript() {
|
|
|
312
375
|
const el = (id) => document.getElementById(id);
|
|
313
376
|
const say = (n, txt) => { el("o" + n).textContent = txt; el("s" + n).classList.add("done"); };
|
|
314
377
|
const PAY = "x-payment";
|
|
378
|
+
let NET = "testnet"; // which explorer the proof links point at
|
|
379
|
+
let TOKEN = ""; // the token the demo is priced in, for readable amounts
|
|
315
380
|
|
|
316
381
|
async function main() {
|
|
317
382
|
const btn = el("go"); btn.disabled = true;
|
|
@@ -321,6 +386,8 @@ function demoScript() {
|
|
|
321
386
|
try {
|
|
322
387
|
const r = await fetch(ROUTE, { cache: "no-store" });
|
|
323
388
|
terms = JSON.parse(r.headers.get(PAY) || "null");
|
|
389
|
+
NET = String((terms && terms.network) || "testnet").replace("stellar:", "");
|
|
390
|
+
TOKEN = String((terms && terms.token && terms.token.code) || "");
|
|
324
391
|
say(1, "HTTP " + r.status + "\\n" + JSON.stringify(terms, null, 2));
|
|
325
392
|
if (r.status !== 402) { say(1, "HTTP " + r.status + " — this showcase is not asking for payment right now"); return; }
|
|
326
393
|
} catch (e) { say(1, "request failed: " + e); return; }
|
|
@@ -352,8 +419,18 @@ function demoScript() {
|
|
|
352
419
|
say(3, "HTTP 202 — parked as " + (parked.handle || "?") + "\\n" + (parked.reason || ""));
|
|
353
420
|
|
|
354
421
|
// 4 — wait for a person. The page polls the seller's own status route.
|
|
355
|
-
|
|
422
|
+
// Say plainly what is happening: the seller's phone is the only thing that
|
|
423
|
+
// can sign this, and a visitor on their own laptop cannot make that happen.
|
|
424
|
+
// Bare silence here reads as a broken demo.
|
|
425
|
+
say(4, "parked. Now the seller's phone decides — that signature cannot come from this browser.\\nwaiting…");
|
|
356
426
|
const seen = await poll(parked.statusUrl);
|
|
427
|
+
if (seen.status === "timed out waiting for a human") {
|
|
428
|
+
el("o4").textContent = "nobody signed, so nothing was paid — exactly as designed.\\n"
|
|
429
|
+
+ "Run the loop again when the seller is at their phone.";
|
|
430
|
+
await showLast();
|
|
431
|
+
btn.disabled = false;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
357
434
|
say(4, "status: " + seen.status + (seen.reason ? "\\n" + seen.reason : ""));
|
|
358
435
|
if (seen.status !== "settled" && seen.status !== "paid") { btn.disabled = false; return; }
|
|
359
436
|
|
|
@@ -375,11 +452,29 @@ function demoScript() {
|
|
|
375
452
|
for (let i = 0; i < 150; i++) {
|
|
376
453
|
const j = await (await fetch(u, { cache: "no-store" })).json();
|
|
377
454
|
if (j.status && j.status !== "pending" && j.status !== "awaiting_phone") return j;
|
|
455
|
+
// ~16 s with nobody at the phone: show that this loop does settle, instead
|
|
456
|
+
// of letting a visitor sit on a spinner and conclude the product is fake.
|
|
457
|
+
if (i === 8) await showLast();
|
|
378
458
|
await new Promise((r) => setTimeout(r, 2000));
|
|
379
459
|
}
|
|
380
460
|
return { status: "timed out waiting for a human" };
|
|
381
461
|
}
|
|
382
462
|
|
|
463
|
+
/**
|
|
464
|
+
* The most recent payment THIS route settled, as a public hash. It is not a
|
|
465
|
+
* substitute for the live approval — it is labelled as a different payment —
|
|
466
|
+
* it is the answer to "okay, but has this ever actually happened?".
|
|
467
|
+
*/
|
|
468
|
+
async function showLast() {
|
|
469
|
+
try {
|
|
470
|
+
const last = await (await fetch("/demo/last", { cache: "no-store" })).json();
|
|
471
|
+
if (!last || !last.tx) return;
|
|
472
|
+
el("o4").textContent += "\\n\\nthe same loop, settled for real on " + new Date(last.at).toLocaleString()
|
|
473
|
+
+ " (" + Number(last.price) + (TOKEN ? " " + TOKEN : "") + ") — verify it without this page:\\nhttps://stellar.expert/explorer/"
|
|
474
|
+
+ NET + "/transaction/" + last.tx;
|
|
475
|
+
} catch { /* the demo still works without the extra proof */ }
|
|
476
|
+
}
|
|
477
|
+
|
|
383
478
|
el("go").addEventListener("click", main);
|
|
384
479
|
})();`;
|
|
385
480
|
}
|
package/dist/vault.d.ts
CHANGED
|
@@ -171,6 +171,19 @@ export declare class StellarVault {
|
|
|
171
171
|
* so a stolen agent key cannot use it and a stolen phone cannot use it alone.
|
|
172
172
|
*/
|
|
173
173
|
release(payee: string, amount: Amount, principal: Keypair, approver: Keypair, memo?: string): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
|
|
174
|
+
/**
|
|
175
|
+
* Create an account that does not exist yet, with a starting balance. Same two
|
|
176
|
+
* signatures as release(), because it is the vault's money moving.
|
|
177
|
+
*
|
|
178
|
+
* This exists because a plain payment to an address that has never been
|
|
179
|
+
* funded is refused by the ledger (`op_no_destination`) — proven on the mainnet
|
|
180
|
+
* vault when topping up the demo's agent account. So "send XLM to a brand new
|
|
181
|
+
* wallet" (the demo agent, a seller's fresh payee, an owner's new cold
|
|
182
|
+
* account) needs createAccount, not payment. Only XLM can do this: a token
|
|
183
|
+
* payment to an unfunded account can never work, because that account would
|
|
184
|
+
* also need a trustline.
|
|
185
|
+
*/
|
|
186
|
+
fund(destination: string, amount: Amount, principal: Keypair, approver: Keypair, memo?: string): Promise<Horizon.HorizonApi.SubmitTransactionResponse>;
|
|
174
187
|
/**
|
|
175
188
|
* Kill every outstanding mandate at once. Any accepted transaction bumps the
|
|
176
189
|
* vault's sequence, which invalidates all pre-authorized hashes pointing at a
|
package/dist/vault.js
CHANGED
|
@@ -395,6 +395,28 @@ export class StellarVault {
|
|
|
395
395
|
b.addMemo(Memo.text(memo));
|
|
396
396
|
return sub(this.horizon, PASSPHRASE[this.network], b.build(), principal, approver);
|
|
397
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Create an account that does not exist yet, with a starting balance. Same two
|
|
400
|
+
* signatures as release(), because it is the vault's money moving.
|
|
401
|
+
*
|
|
402
|
+
* This exists because a plain payment to an address that has never been
|
|
403
|
+
* funded is refused by the ledger (`op_no_destination`) — proven on the mainnet
|
|
404
|
+
* vault when topping up the demo's agent account. So "send XLM to a brand new
|
|
405
|
+
* wallet" (the demo agent, a seller's fresh payee, an owner's new cold
|
|
406
|
+
* account) needs createAccount, not payment. Only XLM can do this: a token
|
|
407
|
+
* payment to an unfunded account can never work, because that account would
|
|
408
|
+
* also need a trustline.
|
|
409
|
+
*/
|
|
410
|
+
async fund(destination, amount, principal, approver, memo) {
|
|
411
|
+
if (!this.isNative)
|
|
412
|
+
throw new Error("only XLM can create an account; a token needs a trustline first");
|
|
413
|
+
const b = (await this.tb(this.address))
|
|
414
|
+
.addOperation(Operation.createAccount({ destination, startingBalance: amount }))
|
|
415
|
+
.setTimeout(300);
|
|
416
|
+
if (memo)
|
|
417
|
+
b.addMemo(Memo.text(memo));
|
|
418
|
+
return sub(this.horizon, PASSPHRASE[this.network], b.build(), principal, approver);
|
|
419
|
+
}
|
|
398
420
|
/**
|
|
399
421
|
* Kill every outstanding mandate at once. Any accepted transaction bumps the
|
|
400
422
|
* vault's sequence, which invalidates all pre-authorized hashes pointing at a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"phone-app/icon-512.png",
|
|
25
25
|
"portal-app/portal.js",
|
|
26
26
|
"portal-app/portal.css",
|
|
27
|
+
"portal-app/rules.js",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"node": ">=20"
|
|
33
34
|
},
|
|
34
35
|
"scripts": {
|
|
35
|
-
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js dist/mcp.js",
|
|
36
37
|
"test:dist": "npm run build && tsx stellar/dist-check.ts",
|
|
37
38
|
"demo": "node sdk/demo.ts",
|
|
38
39
|
"demo:stellar": "tsx stellar/demo.ts",
|
|
@@ -44,12 +45,15 @@
|
|
|
44
45
|
"test:stellar": "tsx stellar/demo.ts",
|
|
45
46
|
"test:portal": "tsx stellar/xss-check.ts",
|
|
46
47
|
"test:mandate": "tsx stellar/mandate-check.ts",
|
|
48
|
+
"test:policy": "tsx stellar/policy-check.ts",
|
|
47
49
|
"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 && tsx phone-app/ui-check.ts",
|
|
48
50
|
"test:ios": "tsx ios/bridge-check.ts",
|
|
49
51
|
"test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
|
|
50
52
|
"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\"",
|
|
51
53
|
"typecheck": "tsc --noEmit",
|
|
52
54
|
"app": "tsx stellar/cli.ts",
|
|
55
|
+
"mcp": "tsx stellar/mcp.ts",
|
|
56
|
+
"test:mcp": "tsx stellar/mcp-check.ts",
|
|
53
57
|
"app:mainnet": "tsx stellar/cli.ts setup --mainnet --file paydlux-mainnet.json --token XLM --max 2 --daily 20",
|
|
54
58
|
"fund:check": "tsx stellar/check-address.ts GARPB3CO3JMMED3ETFRSSZXCH2X7UTXW4FNUP3Z7HDMBKAC2H355W4SA",
|
|
55
59
|
"test:showcase": "tsx stellar/showcase-check.ts",
|
|
@@ -59,7 +63,13 @@
|
|
|
59
63
|
"test:donate": "tsx stellar/donate-check.ts",
|
|
60
64
|
"donate:live": "tsx stellar/donate-live.ts",
|
|
61
65
|
"app:mainnet:sell": "tsx stellar/cli.ts sell --file paydlux-mainnet.json --split --tls --price 1 --route /market/data --max 2 --daily 20",
|
|
66
|
+
"app:mainnet:fund": "bash tools/fund-demo-wallet.sh",
|
|
67
|
+
"loop:check": "tsx stellar/mainnet-loop-check.ts",
|
|
62
68
|
"app:testnet:sell": "tsx stellar/cli.ts sell --split --tls --showcase --price 1 --route /market/data --max 10 --daily 50",
|
|
69
|
+
"app:demo": "tsx stellar/cli.ts demo",
|
|
70
|
+
"app:demo:build": "bash tools/make-demo-app.sh",
|
|
71
|
+
"app:mainnet:build": "bash tools/make-demo-app.sh --mainnet",
|
|
72
|
+
"tunnel:service": "bash tools/install-tunnel-service.sh",
|
|
63
73
|
"app:donate": "tsx stellar/cli.ts donate --file paydlux-mainnet.json --tls --price 1",
|
|
64
74
|
"site:qr": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg",
|
|
65
75
|
"address:check": "tsx stellar/check-address.ts",
|
|
@@ -91,18 +101,23 @@
|
|
|
91
101
|
"access": "public"
|
|
92
102
|
},
|
|
93
103
|
"dependencies": {
|
|
104
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
94
105
|
"@stellar/stellar-sdk": "^17.4.0",
|
|
95
|
-
"qrcode-generator": "^2.0.4"
|
|
106
|
+
"qrcode-generator": "^2.0.4",
|
|
107
|
+
"zod": "^4.6.7"
|
|
96
108
|
},
|
|
97
109
|
"devDependencies": {
|
|
98
110
|
"@types/node": "^26.6.6",
|
|
99
111
|
"jsqr": "^1.4.0",
|
|
112
|
+
"qrcode": "^1.5.4",
|
|
100
113
|
"tsx": "^4.23.15",
|
|
101
114
|
"typescript": "^7.0.2",
|
|
102
115
|
"viem": "^2.57.13",
|
|
116
|
+
"wrangler": "^4.158.0",
|
|
103
117
|
"xrpl": "^5.3.0"
|
|
104
118
|
},
|
|
105
119
|
"bin": {
|
|
106
|
-
"paydlux": "dist/cli.js"
|
|
120
|
+
"paydlux": "dist/cli.js",
|
|
121
|
+
"paydlux-mcp": "dist/mcp.js"
|
|
107
122
|
}
|
|
108
123
|
}
|
package/portal-app/portal.css
CHANGED
|
@@ -19,4 +19,26 @@ button{border:0;border-radius:10px;padding:12px 18px;font-size:15px;font-weight:
|
|
|
19
19
|
details summary{cursor:pointer}
|
|
20
20
|
.phlink{color:var(--gold);text-decoration:underline;font-size:12px}
|
|
21
21
|
.foot{margin-top:28px;color:var(--muted);font-size:12px}
|
|
22
|
+
|
|
23
|
+
/* --- spending rules page --- */
|
|
24
|
+
h2{font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:28px 0 10px}
|
|
25
|
+
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
|
26
|
+
label{display:block;color:var(--muted);font-size:12px}
|
|
27
|
+
input,textarea{display:block;width:100%;margin-top:6px;background:#141715;color:var(--text);
|
|
28
|
+
border:1px solid var(--line);border-radius:10px;padding:11px 12px;font:15px/1.4 ui-monospace,monospace}
|
|
29
|
+
textarea{resize:vertical}
|
|
30
|
+
input:focus,textarea:focus{outline:none;border-color:var(--gold)}
|
|
31
|
+
.note{color:var(--muted);font-size:13px;margin:10px 0}
|
|
32
|
+
.note b{color:var(--gold)}
|
|
33
|
+
table.cats,table.map{margin-top:6px}
|
|
34
|
+
table.cats td{padding:8px 6px}
|
|
35
|
+
.addcat{display:flex;gap:10px;align-items:end;margin-top:10px;flex-wrap:wrap}
|
|
36
|
+
.addcat input{flex:1;min-width:140px}
|
|
37
|
+
.addcat button{width:auto;flex:0 0 auto}
|
|
38
|
+
.save{margin-top:26px}
|
|
39
|
+
.save button{width:auto;min-width:180px}
|
|
40
|
+
button.mini{display:inline;width:auto;padding:4px 10px;font-size:12px;margin:0}
|
|
41
|
+
.nav{color:var(--gold);text-decoration:underline}
|
|
42
|
+
.saved{color:var(--green);font-weight:700}
|
|
43
|
+
@media(max-width:640px){.grid{grid-template-columns:1fr}}
|
|
22
44
|
@media(max-width:640px){.body{max-width:180px}}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The spending-rules page's client half. Served by the desk at /rules.js.
|
|
2
|
+
//
|
|
3
|
+
// The page is a plain HTML form, so it works with JavaScript off. This file
|
|
4
|
+
// only adds the two things a form cannot do by itself: a Remove button that
|
|
5
|
+
// queues the row for deletion and submits, and a confirm before a rule that
|
|
6
|
+
// gates money is loosened.
|
|
7
|
+
//
|
|
8
|
+
// No inline handlers, so the page's CSP keeps script-src 'self'. Category names
|
|
9
|
+
// arrive from the server already reduced to [a-z0-9_-]; they are still read
|
|
10
|
+
// through dataset, never interpolated into markup, so nothing here can be
|
|
11
|
+
// turned into HTML by a name.
|
|
12
|
+
const form = document.getElementById("rules");
|
|
13
|
+
if (!form) throw new Error("rules page has no #rules form");
|
|
14
|
+
|
|
15
|
+
// Hidden fields the server reads as "delete these". Accumulating rather than
|
|
16
|
+
// replacing means a human can remove three rows before pressing Save.
|
|
17
|
+
const hidden = (name) => {
|
|
18
|
+
let el = form.querySelector(`input[name="${name}"]`);
|
|
19
|
+
if (!el) {
|
|
20
|
+
el = document.createElement("input");
|
|
21
|
+
el.type = "hidden";
|
|
22
|
+
el.name = name;
|
|
23
|
+
form.appendChild(el);
|
|
24
|
+
}
|
|
25
|
+
return el;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
document.addEventListener("click", (e) => {
|
|
29
|
+
const b = e.target.closest("button[data-del]");
|
|
30
|
+
if (!b) return;
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
const field = b.dataset.map ? "delmap" : "delcat";
|
|
33
|
+
const el = hidden(field);
|
|
34
|
+
const val = b.dataset.del;
|
|
35
|
+
const have = el.value ? el.value.split(",") : [];
|
|
36
|
+
if (!have.includes(val)) have.push(val);
|
|
37
|
+
el.value = have.join(",");
|
|
38
|
+
// The row goes immediately: the human sees the rule stop protecting them
|
|
39
|
+
// before they press Save, which is the point of the confirm.
|
|
40
|
+
b.closest("tr")?.remove();
|
|
41
|
+
form.requestSubmit();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// A wider ceiling is the one edit that can only hurt. The ledger still caps a
|
|
45
|
+
// single payment at the mandate amount, but the daily budget and the seller
|
|
46
|
+
// list are ours to count, so raising them is a real loosening. Ask once.
|
|
47
|
+
const limits = ["maxPerPayment", "dailyBudget"];
|
|
48
|
+
const start = Object.fromEntries(
|
|
49
|
+
limits.map((n) => [n, form.querySelector(`[name="${n}"]`)?.value ?? ""]));
|
|
50
|
+
|
|
51
|
+
form.addEventListener("submit", (e) => {
|
|
52
|
+
const raised = limits.filter((n) => {
|
|
53
|
+
const now = Number(form.querySelector(`[name="${n}"]`)?.value);
|
|
54
|
+
const was = Number(start[n]);
|
|
55
|
+
return Number.isFinite(now) && Number.isFinite(was) && now > was;
|
|
56
|
+
});
|
|
57
|
+
if (!raised.length) return;
|
|
58
|
+
const ok = confirm(
|
|
59
|
+
`This raises ${raised.join(" and ")}. Higher limits mean the agent may ask you for ` +
|
|
60
|
+
`more money, more often. Continue?`);
|
|
61
|
+
if (!ok) e.preventDefault();
|
|
62
|
+
});
|