@paydlux/agent 0.1.21 → 0.1.22

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/cli.js CHANGED
@@ -322,6 +322,7 @@ async function sell() {
322
322
  // --split is the honest mode: this process cannot approve anything.
323
323
  approver: split ? undefined : keys.approver,
324
324
  split,
325
+ agentSeed: keys.agent,
325
326
  portalKey,
326
327
  publicHost,
327
328
  agentKeys: [pub(keys.agent)],
@@ -846,21 +847,50 @@ async function escrow() {
846
847
  const keys = data.keys ?? {};
847
848
  const agent = Keypair.fromSecret(keys.agent);
848
849
  if (!keys.approver) {
850
+ // The recommended setup: the desk is running with the phone approver, so
851
+ // hand the release to the DESK instead of writing a file nothing reads.
852
+ // The desk queues it, the phone signs the claim, the desk submits it.
853
+ const deskPort = Number(arg("desk") ?? 4022);
854
+ const portalKey = data.desk?.portalKey;
855
+ if (portalKey) {
856
+ try {
857
+ const res = await fetch(`http://127.0.0.1:${deskPort}/escrow?k=${encodeURIComponent(portalKey)}`, {
858
+ method: "POST",
859
+ headers: { "content-type": "application/json" },
860
+ body: JSON.stringify({
861
+ escrowId: hit.balanceId, order: hit.memo || undefined,
862
+ amount: hit.amount,
863
+ asset: hit.asset === "native" ? "XLM" : hit.asset.split(":")[0],
864
+ buyer: hit.buyer,
865
+ }),
866
+ });
867
+ if (res.ok) {
868
+ const queued = await res.json();
869
+ console.log(`\nrelease queued on the approval desk as ${queued.id}.`);
870
+ console.log(` open: ${flag("tls") ? `https://${PUBLIC_HOST}` : `http://127.0.0.1:${deskPort}`}/phone/?k=${portalKey}`);
871
+ console.log(` the order is ${hit.memo || hit.balanceId} — approve on the phone only if you delivered it.`);
872
+ console.log(` the phone signs the claim; the desk submits it. vault balance updates after.\n`);
873
+ return;
874
+ }
875
+ console.log(`the desk answered ${res.status}; falling back to the manual challenge.\n`);
876
+ }
877
+ catch {
878
+ // No desk listening on that port. Fall through to the file path.
879
+ }
880
+ }
849
881
  const challenge = await prepareEscrowRelease(network, hit.balanceId, vault.address, agent);
850
882
  console.log(`\nthis laptop holds one of the two keys, so the release needs your phone.\n`);
851
883
  console.log(describeChallenge(challenge));
852
- console.log(`\n open: ${flag("tls") ? `https://${PUBLIC_HOST}` : "http://127.0.0.1:4022"}/?k=${data.desk?.portalKey ?? "…"}`);
853
- console.log(` the order id is ${hit.memo || hit.balanceId} — sign only if you delivered it.\n`);
854
- // Written out so the second signature can be added by whatever holds the
855
- // approver key, without retyping XDR. Said exactly, because a promise here
856
- // that nothing implements is a lie in the seller's terminal: no process
857
- // reads this file back on its own.
884
+ console.log(`\n the order id is ${hit.memo || hit.balanceId} — sign only if you delivered it.\n`);
885
+ console.log(`no approval desk is answering on port ${deskPort}. start one with \`paydlux sell\` (or the`);
886
+ console.log(`app:mainnet:sell script) and re-run this command, and the release goes to your phone.`);
887
+ console.log(`or finish it on a machine that holds the approver key.\n`);
888
+ // Written out so the second signature can still be added by hand from a
889
+ // machine that holds the approver key. Said exactly: nothing reads this
890
+ // file back on its own.
858
891
  const file = path.replace(/\.json$/, ".escrow-release.json");
859
892
  writeFileSync(file, JSON.stringify({ order: hit.memo || hit.balanceId, challenge }, null, 2), { mode: 0o600 });
860
- console.log(`challenge written to ${file} (0600).`);
861
- console.log(`nothing completes this by itself. finish it from a machine that holds the approver key,`);
862
- console.log(`or run this command there. the phone approver releases PAYMENTS today; releasing ESCROW`);
863
- console.log(`from the phone is not wired yet, so until it is, use the second key's machine.\n`);
893
+ console.log(`challenge also written to ${file} (0600) for the manual path.\n`);
864
894
  return;
865
895
  }
866
896
  const out = await claimEscrow(network, hit.balanceId, vault.address, { agent, approver: Keypair.fromSecret(keys.approver) });
package/dist/desk.d.ts CHANGED
@@ -32,6 +32,20 @@ export type DeskRecord = {
32
32
  challenge?: ApprovalChallenge;
33
33
  qr?: string;
34
34
  signedXdr?: string;
35
+ /**
36
+ * What the human is being asked to authorise. `payment` is the vault paying
37
+ * money OUT. `escrow` is the vault claiming a claimable balance the buyer
38
+ * already locked — money coming IN. The distinction is load-bearing: the
39
+ * spending mandate caps payments out, so it must not touch an escrow
40
+ * release, and the signed transaction is verified against a different
41
+ * operation type.
42
+ */
43
+ kind?: "payment" | "escrow";
44
+ /** escrow records: the claimable balance to claim, and the order it belongs to */
45
+ escrowId?: string;
46
+ order?: string;
47
+ /** escrow records: the asset the balance pays out ("XLM", "USDC", ...) */
48
+ asset?: string;
35
49
  };
36
50
  export type DeskStore = DeskRecord;
37
51
  export type DeskConfig = {
@@ -46,6 +60,15 @@ export type DeskConfig = {
46
60
  * the desk submits it. The approver seed never touches this machine.
47
61
  */
48
62
  split?: boolean;
63
+ /**
64
+ * The agent seed (the machine-side key). Payments are built with the
65
+ * principal key; an ESCROW CLAIM is built with the agent key — that is the
66
+ * key pair the escrow was designed around (agent + phone approver). The desk
67
+ * needs it to build the release challenge, exactly as it needs the principal
68
+ * seed to build a payment challenge. It is not the approving half: the claim
69
+ * still needs the phone's signature to reach the vault's threshold of 2.
70
+ */
71
+ agentSeed?: string;
49
72
  /** how long a human has to decide before the payment is dead */
50
73
  decideWithinSeconds?: number;
51
74
  port: number;
@@ -99,6 +122,27 @@ export declare class ApprovalDesk {
99
122
  /** Every record carrying this key, newest first. */
100
123
  allByKey(key: string): DeskRecord[];
101
124
  findByKey(key: string): DeskRecord | null;
125
+ /**
126
+ * Queue an escrow release for the human. This is the missing half of
127
+ * `paydlux escrow --release` on a --split vault: the money already sits in a
128
+ * claimable balance the buyer locked, so releasing it is the vault CLAIMING
129
+ * that balance — money coming IN, not going out. The claim still needs the
130
+ * vault's threshold of 2 (agent + approver), and the approver half lives on
131
+ * the phone, so it goes through the same challenge/sign/submit loop as a
132
+ * payment, tagged `kind: "escrow"` so the spending mandate — which caps
133
+ * money OUT — never touches it.
134
+ *
135
+ * The generic fields (uri/price/body) are filled so the portal page renders
136
+ * the row without knowing about escrow; the structured fields carry the
137
+ * truth the verification uses.
138
+ */
139
+ addEscrow(input: {
140
+ escrowId: string;
141
+ order?: string;
142
+ amount: string;
143
+ asset: string;
144
+ buyer?: string;
145
+ }): DeskRecord;
102
146
  get(id: string): DeskRecord | null;
103
147
  list(): DeskRecord[];
104
148
  /**
@@ -127,6 +171,21 @@ export declare class ApprovalDesk {
127
171
  approve(id: string): Promise<DeskRecord>;
128
172
  /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
129
173
  private requestPhoneSignature;
174
+ /**
175
+ * Split mode step 1 for an ESCROW release: build the claim, sign it with the
176
+ * agent key, hand it to the phone. `prepareEscrowRelease` reads the ledger
177
+ * and refuses a balance that does not exist or whose timer has not passed,
178
+ * so a queued-but-fake escrow dies here with a real reason instead of
179
+ * reaching the human as a signable challenge.
180
+ */
181
+ private requestEscrowSignature;
182
+ /**
183
+ * Split mode step 2 for an ESCROW release: the phone signed the claim.
184
+ * No mandate check — this is money coming IN — and no recordSpend: the
185
+ * daily budget counts payments OUT, and counting a release against it would
186
+ * make the seller's own earned money unclaimable.
187
+ */
188
+ private completeEscrow;
130
189
  /**
131
190
  * Split mode step 2: the phone answered. `signedXdr` is the transaction with
132
191
  * the approver signature added, returned by signChallenge() on the phone.
package/dist/desk.js CHANGED
@@ -19,6 +19,7 @@ import { TransactionBuilder } from "@stellar/stellar-sdk";
19
19
  import { passphrase } from "./vault.js";
20
20
  import { portalPage } from "./portal.js";
21
21
  import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
22
+ import { claimEscrow, prepareEscrowRelease } from "./escrow.js";
22
23
  /**
23
24
  * The approver app is plain static files under phone-app/. Read on each request
24
25
  * rather than bundled, so the desk and the app can never drift apart.
@@ -121,6 +122,37 @@ function verifyAgainstChallenge(signedXdr, rec) {
121
122
  function recNetwork(rec) {
122
123
  return rec.challenge.network;
123
124
  }
125
+ /**
126
+ * Same binding check as verifyAgainstChallenge, for an escrow release. The
127
+ * signed transaction must be the claim the desk issued (same hash), carry both
128
+ * signatures, and — the escrow-specific part — claim EXACTLY the balance this
129
+ * record names. Without that last check a signature collected for one escrow
130
+ * could be replayed against another order's queue entry.
131
+ */
132
+ function verifyEscrowAgainstChallenge(signedXdr, rec) {
133
+ const ch = rec.challenge;
134
+ let signed;
135
+ try {
136
+ signed = TransactionBuilder.fromXDR(signedXdr, passphrase(recNetwork(rec)));
137
+ }
138
+ catch {
139
+ return "the phone returned something that is not a valid transaction for this network";
140
+ }
141
+ const hash = Buffer.from(signed.hash()).toString("hex");
142
+ if (hash !== ch.hash) {
143
+ return `signed a different transaction (${hash.slice(0, 12)}… was not issued for ${rec.id}, which is ${ch.hash.slice(0, 12)}…)`;
144
+ }
145
+ if (signed.signatures.length < 2) {
146
+ return `only ${signed.signatures.length} signature(s); the vault needs 2`;
147
+ }
148
+ const op = signed.operations?.[0];
149
+ if (!op || op.type !== "claimClaimableBalance")
150
+ return "the transaction is not an escrow claim";
151
+ if (op.balanceId !== rec.escrowId) {
152
+ return `the claim targets ${String(op.balanceId).slice(0, 12)}…, not the escrow ${String(rec.escrowId).slice(0, 12)}… this approval is for`;
153
+ }
154
+ return null;
155
+ }
124
156
  function readBody(req) {
125
157
  return new Promise((resolve, reject) => {
126
158
  let data = "";
@@ -222,6 +254,46 @@ export class ApprovalDesk {
222
254
  findByKey(key) {
223
255
  return this.allByKey(key)[0] ?? null;
224
256
  }
257
+ /**
258
+ * Queue an escrow release for the human. This is the missing half of
259
+ * `paydlux escrow --release` on a --split vault: the money already sits in a
260
+ * claimable balance the buyer locked, so releasing it is the vault CLAIMING
261
+ * that balance — money coming IN, not going out. The claim still needs the
262
+ * vault's threshold of 2 (agent + approver), and the approver half lives on
263
+ * the phone, so it goes through the same challenge/sign/submit loop as a
264
+ * payment, tagged `kind: "escrow"` so the spending mandate — which caps
265
+ * money OUT — never touches it.
266
+ *
267
+ * The generic fields (uri/price/body) are filled so the portal page renders
268
+ * the row without knowing about escrow; the structured fields carry the
269
+ * truth the verification uses.
270
+ */
271
+ addEscrow(input) {
272
+ this.sweepExpired();
273
+ // One live record per balance id. A seller who runs --release twice must
274
+ // not get two Approve buttons for the same money: the second signature
275
+ // would fail on the ledger (balance already claimed) and confuse the human.
276
+ for (const r of this.records.values()) {
277
+ if (r.kind === "escrow" && r.escrowId === input.escrowId &&
278
+ r.status !== "denied" && r.status !== "failed" && r.status !== "expired")
279
+ return r;
280
+ }
281
+ const id = `esc_${input.escrowId.slice(-8)}${randomBytes(3).toString("hex")}`;
282
+ const label = input.order || `${input.escrowId.slice(0, 16)}…`;
283
+ const rec = {
284
+ id, at: Date.now(),
285
+ uri: `/escrow/${label}`,
286
+ // amount only, like a payment price; the asset rides in `asset` and
287
+ // reaches the phone as `token`, so the row never prints "3.5 XLM XLM".
288
+ price: input.amount,
289
+ body: `escrow release — buyer ${input.buyer ?? "?"} — balance ${input.escrowId}`,
290
+ status: "pending",
291
+ kind: "escrow", escrowId: input.escrowId, order: input.order, asset: input.asset,
292
+ };
293
+ this.records.set(id, rec);
294
+ console.log(`[desk] ${id} escrow release awaiting a human: ${rec.price} (${label})`);
295
+ return rec;
296
+ }
225
297
  get(id) { this.sweepExpired(); return this.records.get(id) ?? null; }
226
298
  list() { this.sweepExpired(); return [...this.records.values()]; }
227
299
  /**
@@ -285,6 +357,27 @@ export class ApprovalDesk {
285
357
  throw new Error("unknown handle " + id);
286
358
  if (rec.status !== "pending")
287
359
  throw new Error(`already ${rec.status}`);
360
+ // An escrow release is money coming IN. The spending mandate caps money
361
+ // going OUT, so applying it here would refuse a release for a budget that
362
+ // the release does not touch — and would make the seller's own money
363
+ // unclaimable on a day they already spent.
364
+ if (rec.kind === "escrow") {
365
+ // dev mode (both keys on this machine): claim straight away.
366
+ if (this.cfg.approver && this.cfg.agentSeed) {
367
+ try {
368
+ const out = await claimEscrow(this.cfg.vault.network, rec.escrowId, this.cfg.vault.address, { agent: Keypair.fromSecret(this.cfg.agentSeed), approver: Keypair.fromSecret(this.cfg.approver) });
369
+ rec.status = "settled";
370
+ rec.tx = out.txHash;
371
+ console.log(`[desk] ${rec.id} ESCROW RELEASED ${rec.price} — ledger ${rec.tx}`);
372
+ }
373
+ catch (e) {
374
+ rec.status = "failed";
375
+ rec.reason = e.message;
376
+ }
377
+ return rec;
378
+ }
379
+ return this.requestEscrowSignature(rec);
380
+ }
288
381
  // Check before asking the phone. A human who approves a payment the mandate
289
382
  // will refuse has spent a Face ID scan and a signature for nothing.
290
383
  if (this.mandateBlock(rec, "refused before asking"))
@@ -344,6 +437,64 @@ export class ApprovalDesk {
344
437
  }
345
438
  return rec;
346
439
  }
440
+ /**
441
+ * Split mode step 1 for an ESCROW release: build the claim, sign it with the
442
+ * agent key, hand it to the phone. `prepareEscrowRelease` reads the ledger
443
+ * and refuses a balance that does not exist or whose timer has not passed,
444
+ * so a queued-but-fake escrow dies here with a real reason instead of
445
+ * reaching the human as a signable challenge.
446
+ */
447
+ async requestEscrowSignature(rec) {
448
+ if (!this.cfg.agentSeed) {
449
+ rec.status = "failed";
450
+ rec.reason = "an escrow release needs the agent seed on this machine to build the claim";
451
+ return rec;
452
+ }
453
+ try {
454
+ const challenge = await prepareEscrowRelease(this.cfg.vault.network, rec.escrowId, this.cfg.vault.address, Keypair.fromSecret(this.cfg.agentSeed));
455
+ // The challenge carries its own id; keep the desk's handle so
456
+ // /challenge/:id and /signed/:id line up with what the queue shows.
457
+ challenge.id = rec.id;
458
+ rec.challenge = challenge;
459
+ rec.qr = challengeQrPayload(challenge);
460
+ rec.status = "awaiting_phone";
461
+ console.log(`[desk] ${rec.id} escrow claim sent to the phone: ${rec.price}`);
462
+ }
463
+ catch (e) {
464
+ rec.status = "failed";
465
+ rec.reason = e.message;
466
+ console.log(`[desk] ${rec.id} escrow claim refused before asking: ${rec.reason}`);
467
+ }
468
+ return rec;
469
+ }
470
+ /**
471
+ * Split mode step 2 for an ESCROW release: the phone signed the claim.
472
+ * No mandate check — this is money coming IN — and no recordSpend: the
473
+ * daily budget counts payments OUT, and counting a release against it would
474
+ * make the seller's own earned money unclaimable.
475
+ */
476
+ async completeEscrow(rec, signedXdr) {
477
+ const problem = verifyEscrowAgainstChallenge(signedXdr, rec);
478
+ if (problem) {
479
+ rec.status = "failed";
480
+ rec.reason = problem;
481
+ console.log(`[desk] ${rec.id} rejected: ${problem}`);
482
+ return rec;
483
+ }
484
+ rec.signedXdr = signedXdr;
485
+ try {
486
+ const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
487
+ rec.status = "settled";
488
+ rec.tx = res.hash;
489
+ console.log(`[desk] ${rec.id} ESCROW RELEASED ${rec.price} — ledger ${rec.tx}`);
490
+ }
491
+ catch (e) {
492
+ rec.status = "failed";
493
+ rec.reason = e.message;
494
+ console.log(`[desk] ${rec.id} failed on the ledger: ${rec.reason}`);
495
+ }
496
+ return rec;
497
+ }
347
498
  /**
348
499
  * Split mode step 2: the phone answered. `signedXdr` is the transaction with
349
500
  * the approver signature added, returned by signChallenge() on the phone.
@@ -359,6 +510,11 @@ export class ApprovalDesk {
359
510
  throw new Error(`not awaiting a phone signature (it is ${rec.status})`);
360
511
  if (!rec.challenge)
361
512
  throw new Error("no challenge was issued for " + id);
513
+ // An escrow release verifies against its own shape (a claim of THIS
514
+ // balance id) and bypasses the spending mandate, which only caps payments
515
+ // going out.
516
+ if (rec.kind === "escrow")
517
+ return this.completeEscrow(rec, signedXdr);
362
518
  // Bind the answer to the question. Without this check the desk would submit
363
519
  // any signed transaction posted to this route — including one the phone
364
520
  // signed for a DIFFERENT payment, at a different price. That is how a
@@ -432,9 +588,14 @@ export class ApprovalDesk {
432
588
  tx: r.tx,
433
589
  /** what the buyer says it wants. Agent-controlled text: render as text. */
434
590
  wants: (r.body ?? "").slice(0, 200),
435
- token: this.cfg.vault.token.getCode(),
591
+ token: r.kind === "escrow" ? r.asset ?? this.cfg.vault.token.getCode() : this.cfg.vault.token.getCode(),
436
592
  expiresAt: r.challenge?.summary.expiresAt,
437
593
  hash: r.challenge?.hash,
594
+ // The phone renders an escrow row differently: it is money IN, and the
595
+ // human verifies the balance id, not a payee.
596
+ kind: r.kind ?? "payment",
597
+ escrowId: r.escrowId,
598
+ order: r.order,
438
599
  }));
439
600
  }
440
601
  /** Serve the pending list as JSON (the portal polls this). */
@@ -635,7 +796,37 @@ export class ApprovalDesk {
635
796
  wants: (rec.body ?? "").slice(0, 200),
636
797
  memo: rec.challenge.summary.memo ?? "",
637
798
  expiresAt: rec.challenge.summary.expiresAt,
799
+ // An escrow challenge renders differently on the phone: the op
800
+ // carries no amount and no payee, so the app shows the amount from
801
+ // here and asks the human to check the balance id.
802
+ kind: rec.kind ?? "payment",
803
+ escrowId: rec.escrowId,
804
+ // The claim op carries no amount, so the phone shows the amount
805
+ // the desk read from the ledger alongside the balance id it puts
806
+ // in front of the human.
807
+ amount: rec.kind === "escrow" ? rec.price : undefined,
808
+ asset: rec.kind === "escrow" ? rec.asset : undefined,
809
+ });
810
+ }
811
+ // The CLI's hand-off for `paydlux escrow --release` on a --split vault:
812
+ // queue this balance on the running desk so the phone can sign it.
813
+ // Key-gated like every other route, and it only queues — approving
814
+ // still needs the human's hold-and-sign on the phone.
815
+ if (url.pathname === "/escrow" && req.method === "POST") {
816
+ const body = await readBody(req);
817
+ const input = JSON.parse(body || "{}");
818
+ if (!input.escrowId)
819
+ return send(400, { error: "escrowId is required" });
820
+ if (!input.amount || !input.asset)
821
+ return send(400, { error: "amount and asset are required" });
822
+ const rec = this.addEscrow({
823
+ escrowId: String(input.escrowId),
824
+ order: input.order ? String(input.order) : undefined,
825
+ amount: String(input.amount),
826
+ asset: String(input.asset),
827
+ buyer: input.buyer ? String(input.buyer) : undefined,
638
828
  });
829
+ return send(200, { id: rec.id, status: rec.status, escrowId: rec.escrowId });
639
830
  }
640
831
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
641
832
  if (m && req.method === "POST") {
package/dist/gateway.d.ts CHANGED
@@ -40,6 +40,12 @@ export type GatewayConfig = {
40
40
  * phone sends back.
41
41
  */
42
42
  split?: boolean;
43
+ /**
44
+ * The agent seed, passed to the desk so it can build an ESCROW RELEASE
45
+ * challenge (the claim is signed agent + phone approver). Same trust level
46
+ * as `principal`: it is the machine-side half, never the approving half.
47
+ */
48
+ agentSeed?: string;
43
49
  deskPort?: number;
44
50
  /** gate the approval portal behind a single unguessable link */
45
51
  portalKey?: string;
package/dist/gateway.js CHANGED
@@ -45,6 +45,7 @@ export class Gateway {
45
45
  port: cfg.deskPort ?? 4022,
46
46
  portalKey: cfg.portalKey,
47
47
  split: cfg.split,
48
+ agentSeed: cfg.agentSeed,
48
49
  publicHost: cfg.publicHost,
49
50
  decideWithinSeconds: cfg.decideWithinSeconds,
50
51
  policy: cfg.policy,
@@ -89,6 +90,18 @@ export class Gateway {
89
90
  : outcome.settled ? "released" : o.escrowId ? "gone" : "awaiting_escrow";
90
91
  if (state === "released" && !opts.includeSettled)
91
92
  continue;
93
+ // A releasable escrow goes to the desk automatically, so the phone sees
94
+ // the release without anyone re-running the CLI. addEscrow is idempotent
95
+ // per balance id, so this is safe to call from a read path.
96
+ if (state === "releasable" && e) {
97
+ this.desk.addEscrow({
98
+ escrowId: o.escrowId,
99
+ order: o.id,
100
+ amount: e.amount,
101
+ asset: e.asset === "native" ? "XLM" : e.asset.split(":")[0],
102
+ buyer: e.buyer,
103
+ });
104
+ }
92
105
  out.push({ ...o, state, escrow: e, outcome });
93
106
  }
94
107
  return out.sort((a, b) => (a.releaseAt ?? 0) - (b.releaseAt ?? 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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",
@@ -65,7 +65,7 @@
65
65
  "address:check": "tsx stellar/check-address.ts",
66
66
  "test:address": "tsx stellar/address-check.ts",
67
67
  "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"
68
+ "test:escrow": "tsx stellar/escrow-check.ts && tsx stellar/escrow-gateway-check.ts && tsx stellar/escrow-phone-check.ts"
69
69
  },
70
70
  "keywords": [
71
71
  "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);