@paydlux/agent 0.1.20 → 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/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") {
@@ -0,0 +1,154 @@
1
+ import { Asset, Keypair } from "@stellar/stellar-sdk";
2
+ import { type Amount, type Network } from "./vault.ts";
3
+ export declare const horizonUrl: (network: Network) => string;
4
+ /** Horizon writes an asset as "native" or "USDC:Gissuer…". */
5
+ export declare function assetToHorizonString(asset: Asset): string;
6
+ export declare function assetFromHorizonString(s: string): Asset;
7
+ /** The deal the buyer must lock into the ledger. */
8
+ export type EscrowTerms = {
9
+ /** the order id the buyer must put in the creating transaction's memo */
10
+ orderId: string;
11
+ asset: Asset;
12
+ amount: Amount;
13
+ /**
14
+ * The account that funds and locks the escrow. Required to create one.
15
+ * Optional when VERIFYING: a seller usually does not care who the buyer is,
16
+ * only that the money is locked to the right order, price and vault. Pin it
17
+ * when the buyer's account was agreed up front, and any other account is
18
+ * refused.
19
+ */
20
+ buyer?: string;
21
+ /** the seller vault allowed to claim after release */
22
+ vault: string;
23
+ /** unix seconds; the vault cannot claim before this, the buyer can refund until this */
24
+ releaseAt: number;
25
+ };
26
+ export type Escrow = {
27
+ balanceId: string;
28
+ amount: Amount;
29
+ asset: string;
30
+ vault: string;
31
+ buyer: string;
32
+ releaseAt: number;
33
+ /** the transaction that created it, and the memo it carried */
34
+ txHash: string;
35
+ memo: string;
36
+ memoType: string;
37
+ /** true once the ledger says the balance is gone (claimed or swept) */
38
+ claimed: boolean;
39
+ };
40
+ export type EscrowCheck = {
41
+ ok: boolean;
42
+ reason?: string;
43
+ escrow?: Escrow;
44
+ };
45
+ /**
46
+ * Read a claimable balance back from the ledger and turn it into an Escrow.
47
+ *
48
+ * Horizon does NOT put the memo on the balance record — the memo lives on the
49
+ * transaction that created it. So the join between "this pile of money" and
50
+ * "that order" is one extra request. It is not optional: without it, two orders
51
+ * of the same price to the same vault are indistinguishable, and a seller could
52
+ * deliver order A and claim the escrow that was locked for order B.
53
+ */
54
+ export declare function readEscrow(network: Network, balanceId: string): Promise<Escrow | null>;
55
+ /**
56
+ * Does this on-chain escrow actually pay THIS deal?
57
+ *
58
+ * Every field is checked against the terms the gateway issued, because the
59
+ * buyer is untrusted: a buyer could lock 0.0000001 XLM, or lock money to a
60
+ * different vault, or set the timer to next year, or reuse the escrow from a
61
+ * previous order. Anything short of an exact match is a refusal.
62
+ */
63
+ export declare function verifyEscrow(network: Network, balanceId: string, want: EscrowTerms, opts?: {
64
+ minReleaseSeconds?: number;
65
+ maxReleaseSeconds?: number;
66
+ /**
67
+ * The refund window being open is a requirement when ACCEPTING a deal —
68
+ * a buyer who locks money with a timer that already expired has locked
69
+ * nothing, because the seller could claim it the instant it lands.
70
+ *
71
+ * It is not a requirement when RELEASING one. An escrow that reached its
72
+ * timer is still a valid escrow; that is the normal, successful case. So
73
+ * the release path passes allowClosedWindow and gets the money it earned.
74
+ */
75
+ allowClosedWindow?: boolean;
76
+ }): Promise<EscrowCheck>;
77
+ /**
78
+ * The balance id of the escrow a submitted transaction created.
79
+ *
80
+ * Horizon does not return this to the submitter, and the id is not derivable
81
+ * from the account alone — it comes out of the transaction RESULT. A buyer who
82
+ * locks money with their own code needs this to know what to put in the
83
+ * `x-payment` header, so it is exported rather than kept private.
84
+ */
85
+ export declare function escrowBalanceId(resultXdr: string, index?: number): string;
86
+ /**
87
+ * Lock an escrow. Needs the BUYER's key: the money leaves the buyer's account,
88
+ * so only the buyer can put it in escrow. The seller never gets a signature on
89
+ * this transaction — that is the point.
90
+ */
91
+ export declare function createEscrow(network: Network, terms: EscrowTerms, buyerKey: Keypair): Promise<{
92
+ escrow: Escrow;
93
+ txHash: string;
94
+ }>;
95
+ /**
96
+ * The seller takes the escrow. Two keys, always: the vault's threshold is 2 and
97
+ * the claim is a medium-threshold operation, so the agent key alone gets
98
+ * tx_bad_auth. `approverKey` lives on the phone in a real deployment.
99
+ *
100
+ * Refuses before the release time rather than letting Horizon reject it, so the
101
+ * caller gets a sentence instead of an error code — and so a buggy scheduler
102
+ * cannot accidentally try to release early in a loop.
103
+ */
104
+ export declare function claimEscrow(network: Network, balanceId: string, vaultAddress: string, keys: {
105
+ agent: Keypair;
106
+ approver: Keypair;
107
+ }): Promise<{
108
+ txHash: string;
109
+ claimedAt: number;
110
+ }>;
111
+ /**
112
+ * Release an escrow with the approver's key on a PHONE.
113
+ *
114
+ * `claimEscrow` needs both keys in one process. In a real deployment the human
115
+ * half never lives on the seller's machine — it lives on their phone. So build
116
+ * the claim here, sign it with the agent key, and hand it out as the same
117
+ * challenge the payment desk already uses: the phone reads a sentence
118
+ * ("claim 3.5 XLM from escrow ord_…"), signs, and sends back XDR.
119
+ *
120
+ * The phone is the only place the second signature can be added. This process
121
+ * cannot sign for it, cannot submit a completed claim, and cannot do anything
122
+ * with the half-signed transaction except wait.
123
+ */
124
+ export declare function prepareEscrowRelease(network: Network, balanceId: string, vaultAddress: string, agent: Keypair): Promise<import("./phone.ts").ApprovalChallenge>;
125
+ /**
126
+ * The buyer takes their money back. No seller signature, no permission, and it
127
+ * works right up to the release second — which is why the seller has to earn
128
+ * the release by proving delivery instead of waiting for the timer.
129
+ */
130
+ export declare function refundEscrow(network: Network, balanceId: string, buyerKey: Keypair): Promise<{
131
+ txHash: string;
132
+ }>;
133
+ /**
134
+ * Every escrow currently waiting on this vault, newest first.
135
+ *
136
+ * Horizon filters claimable balances by claimant, which is exactly the seller's
137
+ * question: "what money is sitting in escrow for me, and when does each one
138
+ * become mine?" A balance that has already been claimed disappears from this
139
+ * list, so it doubles as the pending-release queue.
140
+ */
141
+ export declare function pendingEscrows(network: Network, vaultAddress: string, limit?: number): Promise<Escrow[]>;
142
+ /**
143
+ * Has this escrow been settled, and by whom?
144
+ *
145
+ * A claimed balance vanishes from Horizon, so the answer is in the operations
146
+ * of the transaction that claimed it. This is the proof-of-delivery receipt:
147
+ * the buyer can look up the same number on any explorer.
148
+ */
149
+ export declare function escrowOutcome(network: Network, balanceId: string): Promise<{
150
+ settled: boolean;
151
+ by?: string;
152
+ txHash?: string;
153
+ at?: string;
154
+ }>;