@paydlux/agent 0.1.21 → 0.1.23

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/policy.d.ts CHANGED
@@ -1,3 +1,22 @@
1
+ /**
2
+ * A named bucket of spending with its own ceilings.
3
+ *
4
+ * This is the "transaction categories" idea: the same mandate, sliced by what
5
+ * the money is for. A vault can say "data APIs cost me $5 a day, my coding
6
+ * assistant costs $50 a month" without two vaults or two mandates.
7
+ *
8
+ * A category can only TIGHTEN the base mandate. It has no floor of its own:
9
+ * a category ceiling above the base ceiling is a number that can never bind,
10
+ * because the base ceiling already refused the payment. That asymmetry is the
11
+ * security property — adding a category can never widen what the agent may
12
+ * spend, only narrow it.
13
+ */
14
+ export type CategoryRule = {
15
+ /** ceiling on any single payment in this category */
16
+ maxPerPayment?: number;
17
+ /** total across a calendar day for this category */
18
+ dailyBudget?: number;
19
+ };
1
20
  export type Policy = {
2
21
  /** hard ceiling on any single payment */
3
22
  maxPerPayment: number;
@@ -15,14 +34,23 @@ export type Policy = {
15
34
  * matches what the ledger will actually honour.
16
35
  */
17
36
  maxLiveMandates: number;
37
+ /** named spend buckets, keyed by category name */
38
+ categories?: Record<string, CategoryRule>;
39
+ /** which category a seller's account falls into */
40
+ payeeCategory?: Record<string, string>;
18
41
  };
19
42
  export declare const DEFAULT_POLICY: Policy;
43
+ /** A category name is rendered into HTML and stored in a config file, so it is
44
+ * reduced to a safe alphabet here rather than escaped at every use site. */
45
+ export declare function normalizeCategory(name: unknown): string;
20
46
  export type SpendEvent = {
21
47
  at: number;
22
48
  amount: number;
23
49
  payee: string;
24
50
  uri?: string;
25
51
  mandateHash?: string;
52
+ /** the bucket this spend counts against, when the payee is categorised */
53
+ category?: string;
26
54
  };
27
55
  export type Verdict = {
28
56
  ok: true;
@@ -36,6 +64,20 @@ export declare class PolicyEngine {
36
64
  policy: Policy;
37
65
  constructor(policy?: Partial<Policy>);
38
66
  record(e: SpendEvent): void;
67
+ /** Which bucket a seller's account belongs to, or undefined if uncategorised. */
68
+ categoryOf(payee: string): string | undefined;
69
+ /**
70
+ * Replace the policy from an untrusted source (the rules editor, a config
71
+ * file). Names are normalised and numbers validated here, so a bad value
72
+ * cannot smuggle markup or a negative ceiling into the engine.
73
+ *
74
+ * The owner may widen the counters this file keeps — the daily total and the
75
+ * allowlist are ours to count. What this cannot touch is the per-payment
76
+ * ceiling baked into the mandate's transaction hash: raise it here and the
77
+ * ledger still refuses above the mandate amount. `effectiveMaxPerPayment()`
78
+ * reports the number that actually binds so the UI can say so honestly.
79
+ */
80
+ update(next: Partial<Policy>): Policy;
39
81
  /**
40
82
  * Seed the day's total from the ledger instead of from this process's memory.
41
83
  *
@@ -44,9 +86,11 @@ export declare class PolicyEngine {
44
86
  * holds until the next restart. Seeding from the ledger makes the budget a
45
87
  * property of the vault, not of the uptime of whichever machine ran `sell`.
46
88
  */
47
- seedFromLedger(amount: number): void;
89
+ seedFromLedger(amount: number, category?: string): void;
48
90
  /** Everything spent in the current UTC day. */
49
91
  spentToday(): number;
92
+ /** Everything spent in one category in the current UTC day. */
93
+ spentInCategory(category: string): number;
50
94
  today(): SpendEvent[];
51
95
  /**
52
96
  * Check a request BEFORE any transaction is built. Returns the reason a
@@ -66,5 +110,11 @@ export declare class PolicyEngine {
66
110
  * instead, which is the point of having a human in the loop.
67
111
  */
68
112
  committed?: number;
113
+ /**
114
+ * Same, per category. A category budget that ignores the queue has the same
115
+ * hole the daily budget had: the human approves five payments, the fifth
116
+ * fails, and the approval gesture was wasted.
117
+ */
118
+ committedInCategory?: number;
69
119
  }): Verdict;
70
120
  }
package/dist/policy.js CHANGED
@@ -5,14 +5,81 @@ export const DEFAULT_POLICY = {
5
5
  denylist: [],
6
6
  maxMandateTtl: 3600,
7
7
  maxLiveMandates: 1,
8
+ categories: {},
9
+ payeeCategory: {},
8
10
  };
11
+ /** A category name is rendered into HTML and stored in a config file, so it is
12
+ * reduced to a safe alphabet here rather than escaped at every use site. */
13
+ export function normalizeCategory(name) {
14
+ return String(name ?? "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 32);
15
+ }
9
16
  export class PolicyEngine {
10
17
  events = [];
11
18
  policy;
12
19
  constructor(policy = {}) {
13
20
  this.policy = { ...DEFAULT_POLICY, ...policy };
14
21
  }
15
- record(e) { this.events.push(e); }
22
+ record(e) {
23
+ if (!e.category) {
24
+ const named = this.categoryOf(e.payee);
25
+ if (named)
26
+ e = { ...e, category: named };
27
+ }
28
+ this.events.push(e);
29
+ }
30
+ /** Which bucket a seller's account belongs to, or undefined if uncategorised. */
31
+ categoryOf(payee) {
32
+ const named = this.policy.payeeCategory?.[payee];
33
+ return named ? normalizeCategory(named) : undefined;
34
+ }
35
+ /**
36
+ * Replace the policy from an untrusted source (the rules editor, a config
37
+ * file). Names are normalised and numbers validated here, so a bad value
38
+ * cannot smuggle markup or a negative ceiling into the engine.
39
+ *
40
+ * The owner may widen the counters this file keeps — the daily total and the
41
+ * allowlist are ours to count. What this cannot touch is the per-payment
42
+ * ceiling baked into the mandate's transaction hash: raise it here and the
43
+ * ledger still refuses above the mandate amount. `effectiveMaxPerPayment()`
44
+ * reports the number that actually binds so the UI can say so honestly.
45
+ */
46
+ update(next) {
47
+ const merged = { ...this.policy, ...next };
48
+ // A garbage or blank number falls back to the rule that was in force a
49
+ // moment ago, NOT to DEFAULT_POLICY. Falling back to the default would make
50
+ // a malformed edit silently LOOSEN the mandate — an empty maxPerPayment on
51
+ // a form would turn a 2 into the default 50. Keeping the previous value
52
+ // means a bad field is a no-op, which is the only safe reading of a form
53
+ // that gates money.
54
+ merged.maxPerPayment = positive(merged.maxPerPayment, this.policy.maxPerPayment);
55
+ merged.dailyBudget = positive(merged.dailyBudget, this.policy.dailyBudget);
56
+ merged.maxMandateTtl = positive(merged.maxMandateTtl, this.policy.maxMandateTtl);
57
+ merged.maxLiveMandates = 1; // Stellar sequence binding: see Policy.maxLiveMandates
58
+ merged.allowlist = (merged.allowlist ?? []).filter(isAccount);
59
+ merged.denylist = (merged.denylist ?? []).filter(isAccount);
60
+ const cats = {};
61
+ for (const [name, rule] of Object.entries(merged.categories ?? {})) {
62
+ const key = normalizeCategory(name);
63
+ if (!key)
64
+ continue;
65
+ cats[key] = {
66
+ maxPerPayment: rule?.maxPerPayment === undefined ? undefined : positive(rule.maxPerPayment, merged.maxPerPayment),
67
+ dailyBudget: rule?.dailyBudget === undefined ? undefined : positive(rule.dailyBudget, merged.dailyBudget),
68
+ };
69
+ }
70
+ merged.categories = cats;
71
+ const map = {};
72
+ for (const [payee, name] of Object.entries(merged.payeeCategory ?? {})) {
73
+ if (!isAccount(payee))
74
+ continue;
75
+ const key = normalizeCategory(name);
76
+ if (key && cats[key])
77
+ map[payee] = key;
78
+ }
79
+ merged.payeeCategory = map;
80
+ this.policy = merged;
81
+ return this.policy;
82
+ }
16
83
  /**
17
84
  * Seed the day's total from the ledger instead of from this process's memory.
18
85
  *
@@ -21,14 +88,14 @@ export class PolicyEngine {
21
88
  * holds until the next restart. Seeding from the ledger makes the budget a
22
89
  * property of the vault, not of the uptime of whichever machine ran `sell`.
23
90
  */
24
- seedFromLedger(amount) {
91
+ seedFromLedger(amount, category) {
25
92
  if (!(amount > 0))
26
93
  return;
27
94
  // One synthetic event at the start of the day: it counts toward the total
28
95
  // without pretending to be a payment this process can re-enact.
29
96
  const start = new Date();
30
97
  start.setUTCHours(0, 0, 0, 0);
31
- this.record({ at: start.getTime(), amount, payee: "ledger" });
98
+ this.record({ at: start.getTime(), amount, payee: "ledger", category });
32
99
  }
33
100
  /** Everything spent in the current UTC day. */
34
101
  spentToday() {
@@ -37,6 +104,15 @@ export class PolicyEngine {
37
104
  const t = start.getTime();
38
105
  return this.events.filter((e) => e.at >= t).reduce((a, b) => a + b.amount, 0);
39
106
  }
107
+ /** Everything spent in one category in the current UTC day. */
108
+ spentInCategory(category) {
109
+ const start = new Date();
110
+ start.setUTCHours(0, 0, 0, 0);
111
+ const t = start.getTime();
112
+ return this.events
113
+ .filter((e) => e.at >= t && e.category === category)
114
+ .reduce((a, b) => a + b.amount, 0);
115
+ }
40
116
  today() {
41
117
  const start = new Date();
42
118
  start.setUTCHours(0, 0, 0, 0);
@@ -62,6 +138,22 @@ export class PolicyEngine {
62
138
  if (p.allowlist?.length && !p.allowlist.includes(req.payee)) {
63
139
  return no("allowlist", `${short(req.payee)} is not on the approved seller list`);
64
140
  }
141
+ // Category ceilings, checked last so a refusal names the narrowest rule it
142
+ // actually broke. A category can only tighten the base mandate: the base
143
+ // ceilings above already ran, so a category limit above them is inert.
144
+ const category = this.categoryOf(req.payee);
145
+ if (category) {
146
+ const rule = p.categories?.[category];
147
+ if (rule?.maxPerPayment !== undefined && req.amount > rule.maxPerPayment) {
148
+ return no("category_max_per_payment", `${req.amount} exceeds the ${category} per-payment ceiling of ${rule.maxPerPayment}`);
149
+ }
150
+ if (rule?.dailyBudget !== undefined) {
151
+ const inCat = this.spentInCategory(category) + (req.committedInCategory ?? 0);
152
+ if (inCat + req.amount > rule.dailyBudget) {
153
+ return no("category_daily_budget", `${req.amount} would take the ${category} budget to ${(inCat + req.amount).toFixed(2)}, over its ${rule.dailyBudget} limit (${this.spentInCategory(category).toFixed(2)} paid${req.committedInCategory ? ` + ${req.committedInCategory.toFixed(2)} already approved and waiting` : ""})`);
154
+ }
155
+ }
156
+ }
65
157
  if (req.ttl !== undefined && req.ttl > p.maxMandateTtl) {
66
158
  return no("max_ttl", `mandate would live ${req.ttl}s, longer than the ${p.maxMandateTtl}s limit`);
67
159
  }
@@ -73,3 +165,10 @@ export class PolicyEngine {
73
165
  }
74
166
  const no = (rule, detail) => ({ ok: false, rule, detail });
75
167
  const short = (k) => k.slice(0, 6) + "…" + k.slice(-4);
168
+ /** A Stellar account id, and nothing else. */
169
+ const isAccount = (s) => /^G[A-Z2-7]{55}$/.test(String(s ?? ""));
170
+ /** Coerce to a usable positive number, falling back to the default. */
171
+ function positive(v, fallback) {
172
+ const n = Number(v);
173
+ return Number.isFinite(n) && n > 0 ? n : fallback;
174
+ }
package/dist/portal.d.ts CHANGED
@@ -1,9 +1,26 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { DeskRecord } from "./desk.ts";
3
+ import type { Policy } from "./policy.ts";
3
4
  /**
4
5
  * HTML escaping. Not optional: the request path and body are attacker-chosen
5
6
  * text that lands inside the one page that can authorise a payment.
6
7
  */
7
8
  export declare function esc(v: unknown): string;
8
9
  export declare function portalPage(records: DeskRecord[], vault: string, network: string, qs?: string): string;
10
+ /**
11
+ * The spending rules, as a form the owner can read and edit.
12
+ *
13
+ * This is the surface the "customizable approval policy" request is really
14
+ * about: limits, approved wallets, and categories in one place, instead of
15
+ * command-line flags nobody outside a terminal can see.
16
+ *
17
+ * What it does NOT do is approve anything. Every payment still needs a human
18
+ * signature; these rules only decide what a human gets asked to sign. A rule
19
+ * set here can narrow the agent's reach, never widen what the ledger will
20
+ * accept, and the page says so in the same words the code enforces.
21
+ */
22
+ export declare function rulesPage(policy: Policy, spent: {
23
+ total: number;
24
+ byCategory: Record<string, number>;
25
+ }, vault: string, network: string, qs?: string, saved?: boolean): string;
9
26
  export declare function servePortal(req: IncomingMessage, res: ServerResponse, html: string): void;
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">vault ${esc(vault)} · ${esc(network)} · refreshed <span id="t">now</span></div>
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
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
  ],
@@ -44,6 +45,7 @@
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\"",
@@ -65,7 +67,7 @@
65
67
  "address:check": "tsx stellar/check-address.ts",
66
68
  "test:address": "tsx stellar/address-check.ts",
67
69
  "site:deploy": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg && wrangler deploy",
68
- "test:escrow": "tsx stellar/escrow-check.ts && tsx stellar/escrow-gateway-check.ts"
70
+ "test:escrow": "tsx stellar/escrow-check.ts && tsx stellar/escrow-gateway-check.ts && tsx stellar/escrow-phone-check.ts"
69
71
  },
70
72
  "keywords": [
71
73
  "x402",
package/phone-app/app.js CHANGED
@@ -371,8 +371,16 @@ async function openReview(id) {
371
371
  const rec = await api("/challenge/" + encodeURIComponent(id));
372
372
  const env = X.parseEnvelope(rec.xdr);
373
373
  const op = env.tx.ops[0];
374
- if (env.tx.ops.length !== 1 || op.type !== X.OP_PAYMENT) {
375
- throw new Error("this transaction is not a single payment \u2014 refusing to sign it");
374
+ // Two shapes are signable: a single payment, and a single escrow claim
375
+ // (the vault taking money a buyer locked in a claimable balance). Anything
376
+ // else is refused, and the escrow branch verifies the claim targets the
377
+ // balance id the desk says this approval is for.
378
+ const isClaim = op.type === X.OP_CLAIM_CLAIMABLE_BALANCE;
379
+ if (env.tx.ops.length !== 1 || (op.type !== X.OP_PAYMENT && !isClaim)) {
380
+ throw new Error("this transaction is not a single payment or escrow claim \u2014 refusing to sign it");
381
+ }
382
+ if (isClaim && rec.escrowId && op.balanceId !== rec.escrowId) {
383
+ throw new Error("the claim targets a different balance than this approval is for \u2014 refusing");
376
384
  }
377
385
  const { hash } = await X.transactionHash(rec.xdr, X.NETWORK_ID[rec.network] || rec.network);
378
386
  if (rec.hash && hash !== rec.hash) {
@@ -391,10 +399,14 @@ async function openReview(id) {
391
399
  }
392
400
  current = {
393
401
  id, xdr: rec.xdr, hash,
394
- amount: X.formatAmount(op.amount),
395
- token: op.asset.code || "XLM",
396
- issuer: op.asset.issuer || "",
397
- payee: X.encodeAccountEd25519(op.destination.pub),
402
+ // A claim op carries no amount: the money is whatever the ledger says the
403
+ // balance holds. The desk read that number when it queued the release,
404
+ // so the human sees it from the desk, and the balance id next to it is
405
+ // what ties the number to the order.
406
+ amount: isClaim ? String(rec.amount ?? "") : X.formatAmount(op.amount),
407
+ token: isClaim ? String(rec.asset ?? "") : (op.asset.code || "XLM"),
408
+ issuer: isClaim ? "" : (op.asset.issuer || ""),
409
+ payee: isClaim ? op.balanceId : X.encodeAccountEd25519(op.destination.pub),
398
410
  vault: X.encodeAccountEd25519(env.tx.source.pub),
399
411
  // Read the memo out of the envelope, not out of the server's summary:
400
412
  // the envelope is what is being signed, so it is the only source that
@@ -417,6 +429,10 @@ async function openReview(id) {
417
429
  $("amount").append(num, tk);
418
430
  set("wantsBig", current.wants);
419
431
  set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
432
+ // An escrow claim has no payee: the money goes to the vault itself. What
433
+ // the human verifies instead is the balance id — the ledger address of the
434
+ // locked money — so the row is labelled for that.
435
+ $("payeeLabel").textContent = isClaim ? "Escrow balance" : "Payee";
420
436
  set("payee", current.payee);
421
437
  set("vault", current.vault);
422
438
  set("wants", current.wants);
@@ -432,6 +448,10 @@ async function openReview(id) {
432
448
  $("netChip").style.color = current.network === "public" ? "var(--ok)" : "var(--warn)";
433
449
  err("signErr", "");
434
450
  resetHold();
451
+ // The hold button says what it does. Releasing an escrow is not paying
452
+ // anyone: it is the vault taking money a buyer already locked. After
453
+ // resetHold, which resets the label to the payment wording.
454
+ if (isClaim) set("holdLbl", "Hold to release \u0026 sign");
435
455
  show("review", true);
436
456
  show("queue", false);
437
457
  $("review").scrollIntoView({ behavior: "smooth", block: "start" });
@@ -235,7 +235,7 @@
235
235
  <div class="hint" id="wantsBig"></div>
236
236
  <dl class="kv" style="margin-top:16px">
237
237
  <dt>Token</dt><dd id="token"></dd>
238
- <dt>Payee</dt><dd id="payee"></dd>
238
+ <dt id="payeeLabel">Payee</dt><dd id="payee"></dd>
239
239
  <dt>From</dt><dd id="vault"></dd>
240
240
  <dt>Wants</dt><dd id="wants"></dd>
241
241
  <dt>Reference</dt><dd id="memo"></dd>
package/phone-app/xdr.js CHANGED
@@ -76,6 +76,10 @@
76
76
  const SC_ADDRESS = 4; // soroban account: we refuse it
77
77
  const ASSET_NATIVE = 0, ASSET_ALPHANUM4 = 1, ASSET_ALPHANUM12 = 2;
78
78
  const OP_PAYMENT = 1;
79
+ // ClaimClaimableBalance is the escrow release: the vault taking the money the
80
+ // buyer locked. The phone must read it as well as payments, or a seller on a
81
+ // --split vault can never release an escrow from the phone.
82
+ const OP_CLAIM_CLAIMABLE_BALANCE = 15;
79
83
  const PRECOND_NONE = 0, PRECOND_TIME = 1, PRECOND_V2 = 2;
80
84
  const MEMO_NONE = 0, MEMO_TEXT = 1, MEMO_ID = 2, MEMO_HASH = 3, MEMO_RETURN = 4;
81
85
  const STROOPS = 10000000;
@@ -318,14 +322,29 @@
318
322
  const hasSource = r.u32();
319
323
  const source = hasSource ? readMuxedAccount(r) : null;
320
324
  const type = r.u32(); // OperationBody union switch
325
+ if (type === OP_CLAIM_CLAIMABLE_BALANCE) {
326
+ // ClaimableBalanceID is an union: switch word, then 32 raw bytes. Only
327
+ // the balance-id form (switch 0) exists today; anything else is refused
328
+ // rather than skipped, because skipping bytes misreads the rest.
329
+ const which = r.u32();
330
+ if (which !== 0) throw new Error("claimable balance id form " + which + " is not supported; refusing to sign");
331
+ // The balance id AS THE LEDGER AND THE SDK NAME IT: the 4-byte union
332
+ // switch ("00000000") followed by the 32-byte hash. Returning only the
333
+ // hash would disagree with every id the desk and Horizon use, and the
334
+ // desk's "this claim targets THIS escrow" check would never match.
335
+ const balanceId = "00000000" + hex(r.fixed(32));
336
+ // The claim op carries NO amount: the money is whatever the ledger says
337
+ // that balance holds. The human verifies the balance id instead.
338
+ return { type, source, balanceId, destination: null, asset: null, amount: null };
339
+ }
321
340
  if (type !== OP_PAYMENT) {
322
- throw new Error("operation type " + type + " is not a payment; this app only signs payments");
341
+ throw new Error("operation type " + type + " is not a payment or an escrow claim; this app only signs those");
323
342
  }
324
343
  const destination = readMuxedAccount(r);
325
344
  const asset = readAsset(r);
326
345
  const amount = r.i64();
327
346
  if (amount < 0n) throw new Error("payment amount is negative; refusing to sign");
328
- return { type, source, destination, asset, amount: amount.toString() };
347
+ return { type, source, destination, asset, amount: amount.toString(), balanceId: null };
329
348
  }
330
349
 
331
350
  function readTransaction(r) {
@@ -336,7 +355,7 @@
336
355
  const memo = readMemo(r);
337
356
  const numOps = r.u32();
338
357
  if (numOps !== 1) {
339
- throw new Error("this app signs single-payment transactions, and this one has "
358
+ throw new Error("this app signs one operation at a time, and this one has "
340
359
  + numOps + " operations");
341
360
  }
342
361
  const ops = [];
@@ -471,6 +490,6 @@
471
490
  encodeAccountEd25519, decodeAccountEd25519,
472
491
  decodeSecretSeed, encodeSecretSeed, importSecretSeed, importSecretSeedPair,
473
492
  NETWORK_ID, crc16Xmodem,
474
- ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
493
+ ENVELOPE_TYPE_TX, OP_PAYMENT, OP_CLAIM_CLAIMABLE_BALANCE, STROOPS,
475
494
  };
476
495
  })(typeof globalThis !== "undefined" ? globalThis : this);
@@ -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
+ });