@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/gateway.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Paydlux gateway on Stellar — an HTTP resource server that answers 402 and
2
2
  // only serves the body once payment has actually landed on the ledger.
3
3
  //
4
- // Two modes per route:
4
+ // Three modes per route:
5
5
  //
6
6
  // preauth the human already registered a mandate envelope for this seller
7
7
  // and amount. The gateway hands it over, the agent signs, submits,
@@ -11,12 +11,19 @@
11
11
  // holds it at the approval desk, and a person approves or denies.
12
12
  // On approval the vault releases with principal + approver.
13
13
  //
14
+ // escrow the buyer locks the money in a claimable balance the seller
15
+ // cannot touch until the release timer passes. Delivery has to be
16
+ // proven before that timer, and releasing still needs both human
17
+ // keys. Until then the buyer can always take it back.
18
+ //
14
19
  // Either way the agent's key alone can never move money: threshold is 2.
15
20
  import { createServer } from "node:http";
16
21
  import { createHash } from "node:crypto";
17
22
  import { Keypair, StrKey, Transaction } from "@stellar/stellar-sdk";
18
23
  import { StellarTxError, hex, passphrase } from "./vault.js";
19
24
  import { ApprovalDesk } from "./desk.js";
25
+ import { claimEscrow, escrowOutcome, prepareEscrowRelease, readEscrow, verifyEscrow } from "./escrow.js";
26
+ import { submitApproved } from "./phone.js";
20
27
  export const PAY_HEADER = "x-payment";
21
28
  export class Gateway {
22
29
  routes = new Map();
@@ -38,6 +45,7 @@ export class Gateway {
38
45
  port: cfg.deskPort ?? 4022,
39
46
  portalKey: cfg.portalKey,
40
47
  split: cfg.split,
48
+ agentSeed: cfg.agentSeed,
41
49
  publicHost: cfg.publicHost,
42
50
  decideWithinSeconds: cfg.decideWithinSeconds,
43
51
  policy: cfg.policy,
@@ -48,6 +56,87 @@ export class Gateway {
48
56
  const s = await this.cfg.vault.signerSummary();
49
57
  return s.signers.filter((x) => x.type === "preauth-mandate");
50
58
  }
59
+ /**
60
+ * Escrow orders this gateway has issued, keyed by order id.
61
+ *
62
+ * The order id is the ONLY link between a pile of money on the ledger and a
63
+ * piece of content behind a route: Horizon does not put a memo on the balance
64
+ * record, so matching money to a deal means reading the memo off the creating
65
+ * transaction and comparing it to an order we recognise. An escrow whose memo
66
+ * matches nothing here is money locked for a deal this seller never quoted, so
67
+ * it buys nothing.
68
+ */
69
+ orders = new Map();
70
+ /**
71
+ * A stable order id for one request. Deterministic on purpose: a buyer who
72
+ * retries while the escrow settles must land on the same order, and a seller
73
+ * who restarts must be able to recognise the memo on money already locked.
74
+ * It is not a secret and it is not a capability — holding an id buys nothing
75
+ * without the matching escrow on the ledger.
76
+ */
77
+ orderId(uri, price, body) {
78
+ return "ord_" + createHash("sha256")
79
+ .update(`${this.cfg.vault.address}|${uri}|${price}|${body}`).digest("hex").slice(0, 16);
80
+ }
81
+ /** Escrow orders, oldest first, with whatever the ledger says about each. */
82
+ async escrowOrders(opts = {}) {
83
+ const out = [];
84
+ for (const o of this.orders.values()) {
85
+ const e = o.escrowId ? await readEscrow(this.cfg.vault.network, o.escrowId) : null;
86
+ const outcome = o.escrowId ? await escrowOutcome(this.cfg.vault.network, o.escrowId) : { settled: false };
87
+ const now = Math.floor(Date.now() / 1000);
88
+ const state = e
89
+ ? (now >= e.releaseAt ? "releasable" : "locked")
90
+ : outcome.settled ? "released" : o.escrowId ? "gone" : "awaiting_escrow";
91
+ if (state === "released" && !opts.includeSettled)
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
+ }
105
+ out.push({ ...o, state, escrow: e, outcome });
106
+ }
107
+ return out.sort((a, b) => (a.releaseAt ?? 0) - (b.releaseAt ?? 0));
108
+ }
109
+ /**
110
+ * Take the money the buyer locked. Two keys, always — the vault's threshold
111
+ * is 2, so a stolen agent key cannot empty an escrow on its own.
112
+ */
113
+ async releaseEscrow(orderId, keys) {
114
+ const o = this.orders.get(orderId);
115
+ if (!o?.escrowId)
116
+ throw new Error(`no escrow locked for order ${orderId}`);
117
+ const out = await claimEscrow(this.cfg.vault.network, o.escrowId, this.cfg.vault.address, keys);
118
+ this.settled.add("escrow:" + o.escrowId);
119
+ return out;
120
+ }
121
+ /**
122
+ * The split-key release: build the claim, sign it with the agent key, and
123
+ * hand it to the phone. This process cannot finish it — that is the point.
124
+ */
125
+ async escrowReleaseChallenge(orderId, agent) {
126
+ const o = this.orders.get(orderId);
127
+ if (!o?.escrowId)
128
+ throw new Error(`no escrow locked for order ${orderId}`);
129
+ return prepareEscrowRelease(this.cfg.vault.network, o.escrowId, this.cfg.vault.address, agent);
130
+ }
131
+ /** Take the phone's signature back and settle the escrow on the ledger. */
132
+ async submitEscrowRelease(signedXdr, orderId) {
133
+ const o = this.orders.get(orderId);
134
+ if (!o?.escrowId)
135
+ throw new Error(`no escrow locked for order ${orderId}`);
136
+ const out = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
137
+ this.settled.add("escrow:" + o.escrowId);
138
+ return out;
139
+ }
51
140
  /** Register a pre-authorised mandate for a route (needs both human keys). */
52
141
  async authorise(uri, spec, principal, approver) {
53
142
  const r = this.routes.get(uri);
@@ -101,8 +190,8 @@ export class Gateway {
101
190
  agentKeys;
102
191
  requirements(uri) {
103
192
  const r = this.routes.get(uri);
104
- return {
105
- scheme: "stellar-exact",
193
+ const base = {
194
+ scheme: r.mode === "escrow" ? "stellar-escrow" : "stellar-exact",
106
195
  network: `stellar:${this.cfg.network}`,
107
196
  maxAmountRequired: r.price,
108
197
  resource: uri,
@@ -113,6 +202,21 @@ export class Gateway {
113
202
  maxTimeoutSeconds: r.holdSeconds ?? 3600,
114
203
  version: "1",
115
204
  };
205
+ if (r.mode !== "escrow")
206
+ return base;
207
+ // An escrow route tells the buyer how to lock the money, not just what to
208
+ // pay. Without the release window in the 402 the buyer cannot compute a
209
+ // valid timer, and the seller has no deadline to prove delivery against.
210
+ return {
211
+ ...base,
212
+ escrow: {
213
+ vault: this.cfg.vault.address,
214
+ releaseSeconds: r.releaseSeconds ?? 3600,
215
+ // The memo on the creating transaction must be this order id, or the
216
+ // money cannot be matched to a deal and buys nothing.
217
+ memoMustBe: "the order id returned in this response",
218
+ },
219
+ };
116
220
  }
117
221
  /**
118
222
  * Check a proof against the live request before spending anything.
@@ -193,8 +297,12 @@ export class Gateway {
193
297
  const raw = req.headers[PAY_HEADER];
194
298
  if (!raw) {
195
299
  const reqs = this.requirements(uri);
196
- res.writeHead(402, { [PAY_HEADER]: JSON.stringify(reqs) });
197
- res.end(JSON.stringify({ error: "payment_required", accepts: reqs }));
300
+ // An escrow buyer has to know the order id BEFORE locking the money,
301
+ // because the memo on the creating transaction is the only thing that
302
+ // ties that pile of money to this deal. Hand it out in the 402.
303
+ const order = r.mode === "escrow" ? { order: this.orderId(uri, r.price, body) } : {};
304
+ res.writeHead(402, { [PAY_HEADER]: JSON.stringify({ ...reqs, ...order }) });
305
+ res.end(JSON.stringify({ error: "payment_required", accepts: reqs, ...order }));
198
306
  return;
199
307
  }
200
308
  let proof;
@@ -205,8 +313,63 @@ export class Gateway {
205
313
  res.writeHead(400).end("bad payment header");
206
314
  return;
207
315
  }
208
- if (!proof.intent && !proof.envelope) {
209
- res.writeHead(400).end("payment header needs an envelope or an intent");
316
+ if (!proof.intent && !proof.envelope && !proof.escrow) {
317
+ res.writeHead(400).end("payment header needs an envelope, an intent, or an escrow id");
318
+ return;
319
+ }
320
+ // escrow mode: the buyer locked the money in a claimable balance. The
321
+ // seller cannot claim it yet — that is the whole deal. Serving the body
322
+ // now means the buyer still holds the refund right, so the seller's
323
+ // incentive is to deliver first and release later.
324
+ if (r.mode === "escrow") {
325
+ if (!proof.escrow) {
326
+ res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
327
+ res.end(JSON.stringify({ error: "escrow_required", reason: "this route is sold against escrow: lock the payment in a claimable balance first", escrow: this.requirements(uri).escrow }));
328
+ return;
329
+ }
330
+ const id = this.orderId(uri, r.price, body);
331
+ const releaseSeconds = r.releaseSeconds ?? 3600;
332
+ const check = await verifyEscrow(this.cfg.vault.network, proof.escrow, {
333
+ orderId: id,
334
+ asset: this.cfg.vault.token,
335
+ amount: r.price,
336
+ vault: this.cfg.vault.address,
337
+ releaseAt: 0, // not compared; the window checks below do the work
338
+ }, { minReleaseSeconds: 1, maxReleaseSeconds: releaseSeconds + 300 });
339
+ if (!check.ok || !check.escrow) {
340
+ res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
341
+ res.end(JSON.stringify({ error: "escrow_invalid", order: id, reason: check.reason }));
342
+ return;
343
+ }
344
+ const prior = this.orders.get(id);
345
+ this.orders.set(id, {
346
+ id, uri, price: r.price, at: prior?.at ?? Date.now(),
347
+ escrowId: proof.escrow, releaseAt: check.escrow.releaseAt,
348
+ served: (prior?.served ?? 0) + 1,
349
+ });
350
+ const content = getContent(uri);
351
+ res.writeHead(200, {
352
+ "content-type": "application/json",
353
+ "x-payment-response": JSON.stringify({
354
+ escrow: proof.escrow, order: id,
355
+ releaseAt: check.escrow.releaseAt,
356
+ lockTx: check.escrow.txHash,
357
+ }),
358
+ });
359
+ res.end(JSON.stringify({
360
+ data: content,
361
+ order: id,
362
+ escrow: {
363
+ id: proof.escrow,
364
+ amount: check.escrow.amount,
365
+ releaseAt: check.escrow.releaseAt,
366
+ // The buyer's own refund right, spelled out so a client can act on
367
+ // it without reading a spec: claim the balance back before this
368
+ // second, no seller consent needed.
369
+ refundUntil: check.escrow.releaseAt,
370
+ lockTx: check.escrow.txHash,
371
+ },
372
+ }));
210
373
  return;
211
374
  }
212
375
  // approval mode: the agent sends a signed intent, a human decides
package/dist/index.d.ts CHANGED
@@ -12,3 +12,5 @@ export type { Policy, SpendEvent, Verdict } from "./policy.ts";
12
12
  export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.ts";
13
13
  export { txResultName } from "./vault.ts";
14
14
  export type { ApprovalChallenge } from "./phone.ts";
15
+ export { createEscrow, verifyEscrow, readEscrow, claimEscrow, refundEscrow, escrowOutcome, pendingEscrows, escrowBalanceId, assetToHorizonString, assetFromHorizonString, } from "./escrow.ts";
16
+ export type { EscrowTerms, Escrow, EscrowCheck } from "./escrow.ts";
package/dist/index.js CHANGED
@@ -10,3 +10,7 @@ export { PolicyEngine, DEFAULT_POLICY } from "./policy.js";
10
10
  // Split signing: the human's key lives on a phone, not in the seller's process.
11
11
  export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.js";
12
12
  export { txResultName } from "./vault.js";
13
+ // Escrow: native claimable balances, not a contract. The buyer locks the money,
14
+ // the seller cannot touch it until the release timer, and the buyer can always
15
+ // take it back before then.
16
+ export { createEscrow, verifyEscrow, readEscrow, claimEscrow, refundEscrow, escrowOutcome, pendingEscrows, escrowBalanceId, assetToHorizonString, assetFromHorizonString, } from "./escrow.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.20",
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",
@@ -64,7 +64,8 @@
64
64
  "site:qr": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg",
65
65
  "address:check": "tsx stellar/check-address.ts",
66
66
  "test:address": "tsx stellar/address-check.ts",
67
- "site:deploy": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg && wrangler deploy"
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 && tsx stellar/escrow-phone-check.ts"
68
69
  },
69
70
  "keywords": [
70
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);