@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 +40 -10
- package/dist/desk.d.ts +59 -0
- package/dist/desk.js +192 -1
- package/dist/gateway.d.ts +6 -0
- package/dist/gateway.js +13 -0
- package/package.json +2 -2
- package/phone-app/app.js +26 -6
- package/phone-app/index.html +1 -1
- package/phone-app/xdr.js +23 -4
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
|
|
853
|
-
console.log(`
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
//
|
|
857
|
-
//
|
|
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.
|
|
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
|
-
|
|
375
|
-
|
|
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:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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" });
|
package/phone-app/index.html
CHANGED
|
@@ -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
|
|
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
|
|
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);
|