@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/README.md +68 -0
- package/dist/agent.d.ts +14 -0
- package/dist/agent.js +52 -1
- package/dist/cli.js +162 -8
- package/dist/desk.d.ts +59 -0
- package/dist/desk.js +192 -1
- package/dist/escrow.d.ts +154 -0
- package/dist/escrow.js +325 -0
- package/dist/gateway.d.ts +101 -3
- package/dist/gateway.js +170 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/package.json +3 -2
- package/phone-app/app.js +26 -6
- package/phone-app/index.html +1 -1
- package/phone-app/xdr.js +23 -4
package/README.md
CHANGED
|
@@ -264,6 +264,74 @@ Two ledger behaviours we verified rather than assumed, and designed around:
|
|
|
264
264
|
the content twice — so `gateway.ts` checks the ledger for an already-applied
|
|
265
265
|
transaction before shipping anything.
|
|
266
266
|
|
|
267
|
+
### Escrow: proof of delivery, not a promise
|
|
268
|
+
|
|
269
|
+
The claim on the landing page is that a payment can be held until delivery is
|
|
270
|
+
proven. That is only worth anything if the hold is enforced by the network, so
|
|
271
|
+
escrow is built from **native Stellar claimable balances** — not a smart
|
|
272
|
+
contract.
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
paydlux sell --escrow --price 3.5 --release 3600
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A buyer locks the price in a claimable balance with two claimants:
|
|
279
|
+
|
|
280
|
+
| claimant | predicate | what it means |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| the seller's vault | `NOT(abs_before = releaseAt)` | can claim **only after** the release timer, and only with both human keys |
|
|
283
|
+
| the buyer | `abs_before = releaseAt` | can take the money back **any time before** the timer, alone, no seller consent |
|
|
284
|
+
|
|
285
|
+
Why claimable balances instead of a contract:
|
|
286
|
+
|
|
287
|
+
- **No contract to audit.** The rules are a ledger primitive, not code we wrote.
|
|
288
|
+
- **No contract address to get wrong.** A wrong address in a payment path is an
|
|
289
|
+
irreversible loss; there is no address here.
|
|
290
|
+
- **The money never sits in an account the seller controls.** The seller cannot
|
|
291
|
+
spend it, pledge it, or lose it to a bug in their own server.
|
|
292
|
+
- **The reserve is paid by whoever creates the balance and returned when it is
|
|
293
|
+
claimed.** No standing rent for an idle escrow.
|
|
294
|
+
- **The rules hold when the seller's server is down.** Uptime cannot unlock a
|
|
295
|
+
predicate.
|
|
296
|
+
|
|
297
|
+
The seller gets paid by *proving delivery before the timer*, not by waiting it
|
|
298
|
+
out. The buyer's leverage is the refund right, which is real: it works up to the
|
|
299
|
+
release second and needs nobody's permission.
|
|
300
|
+
|
|
301
|
+
Matching money to a deal is the part that is easy to get wrong. Horizon does not
|
|
302
|
+
put a memo on the balance record, so `readEscrow` fetches the creating
|
|
303
|
+
transaction and reads the memo off it. The memo **must** equal the order id the
|
|
304
|
+
gateway handed out in the `402`; an escrow whose memo matches nothing buys
|
|
305
|
+
nothing. Without that join, two orders at the same price to the same vault are
|
|
306
|
+
indistinguishable, and a seller could deliver order A and claim the escrow locked
|
|
307
|
+
for order B.
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
paydlux escrow # what is locked, who locked it, when it releases
|
|
311
|
+
paydlux escrow --release ord_7f3a… # needs both human keys; on --split, prints the phone challenge
|
|
312
|
+
PAYDLUX_BUYER_SEED=S… paydlux escrow --refund <escrow id> # the buyer's way out
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`--release` refuses before the timer in words ("42 min to go") — the ledger would
|
|
316
|
+
refuse the transaction anyway, but a seller deserves to know why before they
|
|
317
|
+
reach for a key.
|
|
318
|
+
|
|
319
|
+
Three suites, all against the live test network, none mocked:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
npm run test:escrow # ledger primitives + the HTTP 402 escrow deal
|
|
323
|
+
npx tsx stellar/escrow-cli-check.ts # the whole thing through the published CLI
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`escrow-check.ts` locks real balances and asserts the ledger's answer: the vault
|
|
327
|
+
cannot claim early, a wrong second key gets `tx_bad_auth`, the buyer's refund
|
|
328
|
+
lands, and after the timer a 2-of-2 claim settles. `escrow-gateway-check.ts`
|
|
329
|
+
drives the product: `402` with escrow terms, the buyer locking money, the body
|
|
330
|
+
served against the escrow id, a different request body refused because the memo
|
|
331
|
+
names the other order, and the release queue flipping from `locked` to
|
|
332
|
+
`releasable`. `escrow-cli-check.ts` runs `dist/cli.js` the way a user does, so a
|
|
333
|
+
command printed in `--help` that is not actually dispatched fails the build.
|
|
334
|
+
|
|
267
335
|
### Real money
|
|
268
336
|
|
|
269
337
|
Circle issues USDC natively on Stellar — not wrapped, not bridged. `vault.ts`
|
package/dist/agent.d.ts
CHANGED
|
@@ -31,6 +31,19 @@ export type PayResponse<T = string> = {
|
|
|
31
31
|
desk?: string;
|
|
32
32
|
/** where THIS buyer polls for the answer (the gateway, no key needed) */
|
|
33
33
|
statusUrl?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Escrow mode: the seller's order id, and what the ledger now says about the
|
|
36
|
+
* locked money. `escrow.refundUntil` is the buyer's own protection — until
|
|
37
|
+
* that second the buyer can claim the money back without the seller.
|
|
38
|
+
*/
|
|
39
|
+
order?: string;
|
|
40
|
+
escrow?: {
|
|
41
|
+
id: string;
|
|
42
|
+
amount: string;
|
|
43
|
+
releaseAt: number;
|
|
44
|
+
refundUntil: number;
|
|
45
|
+
lockTx: string;
|
|
46
|
+
};
|
|
34
47
|
reason?: string;
|
|
35
48
|
requirements?: any;
|
|
36
49
|
};
|
|
@@ -46,6 +59,7 @@ export declare class PaydluxAgent {
|
|
|
46
59
|
get<T = string>(url: string, opts?: {
|
|
47
60
|
mandate?: Mandate;
|
|
48
61
|
body?: string;
|
|
62
|
+
buyerKey?: string;
|
|
49
63
|
}): Promise<PayResponse<T>>;
|
|
50
64
|
/**
|
|
51
65
|
* Sign and submit a mandate envelope directly to Horizon. Used when the
|
package/dist/agent.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// The agent holds one key of two. It can ask, and it can execute a mandate a
|
|
7
7
|
// human already signed. It cannot move money on its own — the ledger refuses.
|
|
8
8
|
import { Horizon, Keypair, Transaction } from "@stellar/stellar-sdk";
|
|
9
|
-
import { passphrase } from "./vault.js";
|
|
9
|
+
import { passphrase, parseToken } from "./vault.js";
|
|
10
|
+
import { createEscrow } from "./escrow.js";
|
|
10
11
|
const PAY_HEADER = "x-payment";
|
|
11
12
|
export class PaydluxAgent {
|
|
12
13
|
key;
|
|
@@ -31,6 +32,10 @@ export class PaydluxAgent {
|
|
|
31
32
|
const reqs = parseRequirements(first.headers.get(PAY_HEADER));
|
|
32
33
|
if (!reqs)
|
|
33
34
|
return { ok: false, status: 402, reason: "seller sent 402 without payment terms", requirements: null };
|
|
35
|
+
// The 402 body carries the order id for escrow routes: the memo on the
|
|
36
|
+
// buyer's lock transaction has to name it, so it must be known before any
|
|
37
|
+
// money is locked.
|
|
38
|
+
const j0 = await first.json().catch(() => ({}));
|
|
34
39
|
if (reqs.mode === "approval") {
|
|
35
40
|
// Tell the seller we want it. The seller parks the request at the
|
|
36
41
|
// approval desk and a person decides. We do not get to decide.
|
|
@@ -50,6 +55,52 @@ export class PaydluxAgent {
|
|
|
50
55
|
statusUrl: j.statusUrl, requirements: reqs,
|
|
51
56
|
reason: j.reason ?? "a human has to approve this payment" };
|
|
52
57
|
}
|
|
58
|
+
if (reqs.mode === "escrow") {
|
|
59
|
+
// Escrow is the opposite of the other two modes: the buyer keeps the
|
|
60
|
+
// power. The money is locked in a claimable balance the seller cannot
|
|
61
|
+
// touch until the release timer, and the buyer can reclaim it any time
|
|
62
|
+
// before then. So the buyer needs its OWN funding key here — not the
|
|
63
|
+
// agent key, which is a signer on a vault and has no business paying.
|
|
64
|
+
if (!opts.buyerKey) {
|
|
65
|
+
return { ok: false, status: 402, requirements: reqs,
|
|
66
|
+
reason: "this route is sold against escrow; pass buyerKey (the account that funds the escrow)" };
|
|
67
|
+
}
|
|
68
|
+
const orderId = j0.order ?? reqs.order;
|
|
69
|
+
if (!orderId)
|
|
70
|
+
return { ok: false, status: 402, requirements: reqs, reason: "seller did not name an order id" };
|
|
71
|
+
const buyerKp = Keypair.fromSecret(opts.buyerKey);
|
|
72
|
+
const releaseSeconds = Number(reqs.escrow?.releaseSeconds ?? 3600);
|
|
73
|
+
let locked;
|
|
74
|
+
try {
|
|
75
|
+
locked = await createEscrow(this.cfg.network, {
|
|
76
|
+
orderId,
|
|
77
|
+
asset: parseToken(String(reqs.token?.code ?? "XLM"), reqs.token?.issuer),
|
|
78
|
+
amount: String(reqs.maxAmountRequired),
|
|
79
|
+
buyer: buyerKp.publicKey(),
|
|
80
|
+
vault: reqs.escrow?.vault ?? reqs.vault,
|
|
81
|
+
releaseAt: Math.floor(Date.now() / 1000) + releaseSeconds,
|
|
82
|
+
}, buyerKp);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return { ok: false, status: 402, requirements: reqs, order: orderId,
|
|
86
|
+
reason: `could not lock the escrow: ${e.message}` };
|
|
87
|
+
}
|
|
88
|
+
const retry = await f(url, {
|
|
89
|
+
method: opts.body ? "POST" : "GET", body: opts.body,
|
|
90
|
+
headers: { [PAY_HEADER]: JSON.stringify({ escrow: locked.escrow.balanceId }) },
|
|
91
|
+
});
|
|
92
|
+
const j = await retry.json().catch(() => ({}));
|
|
93
|
+
if (retry.status === 200) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true, status: 200, data: (j.data ?? j), requirements: reqs,
|
|
96
|
+
order: j.order, escrow: j.escrow,
|
|
97
|
+
// The buyer's protection, spelled out: until escrow.refundUntil this
|
|
98
|
+
// money is still claimable back by the buyer, no seller consent needed.
|
|
99
|
+
tx: j.escrow?.lockTx,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { ok: false, status: retry.status, reason: j.reason ?? "escrow refused", requirements: reqs, order: orderId };
|
|
103
|
+
}
|
|
53
104
|
if (!opts.mandate) {
|
|
54
105
|
return { ok: false, status: 402, requirements: reqs,
|
|
55
106
|
reason: "seller wants a pre-authorised mandate and none was supplied" };
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,8 @@ import { PolicyEngine } from "./policy.js";
|
|
|
27
27
|
import { PUBLIC_HOST, TUNNEL_NAME, runTunnel, tunnelLive, tunnelProblem, tunnelRunning, tunnelServes } from "./tunnel.js";
|
|
28
28
|
import { DEMO_HOST, DEMO_ROUTE, startDemo } from "./showcase.js";
|
|
29
29
|
import { DONATE_HOST, DONATE_PORT, startDonate } from "./donate.js";
|
|
30
|
+
import { claimEscrow, pendingEscrows, prepareEscrowRelease, refundEscrow } from "./escrow.js";
|
|
31
|
+
import { describeChallenge } from "./phone.js";
|
|
30
32
|
export const VAULT_FILE_DEFAULT = "paydlux-vault.json";
|
|
31
33
|
const arg = (name) => {
|
|
32
34
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -202,7 +204,15 @@ async function sell() {
|
|
|
202
204
|
if (!(Number(price) > 0))
|
|
203
205
|
bad("--price must be a positive number");
|
|
204
206
|
const uri = arg("route") ?? "/api/data";
|
|
205
|
-
|
|
207
|
+
if (flag("preauth") && flag("escrow"))
|
|
208
|
+
bad("--preauth and --escrow are different deals: pre-authorised payment, or payment held until delivery is proven. Pick one.");
|
|
209
|
+
const mode = flag("preauth") ? "preauth" : flag("escrow") ? "escrow" : "approval";
|
|
210
|
+
// How long the buyer's money stays locked. The seller needs this window to
|
|
211
|
+
// prove delivery; the buyer is the one who can walk away inside it. Too short
|
|
212
|
+
// and the seller never gets paid; too long and the buyer is stuck waiting.
|
|
213
|
+
const releaseSeconds = Number(arg("release") ?? 3600);
|
|
214
|
+
if (!(releaseSeconds >= 60))
|
|
215
|
+
bad("--release must be at least 60 seconds. A window shorter than that cannot be audited by either side.");
|
|
206
216
|
const tls = flag("tls");
|
|
207
217
|
const split = flag("split");
|
|
208
218
|
// Real money gets a hard ceiling, and it is not a suggestion. The ledger
|
|
@@ -219,11 +229,21 @@ async function sell() {
|
|
|
219
229
|
const priceNum = Number(price);
|
|
220
230
|
if (!(priceNum > 0))
|
|
221
231
|
bad(`--price ${price} is not a number greater than zero.`);
|
|
222
|
-
|
|
223
|
-
|
|
232
|
+
// The mandate ceiling is a limit on what YOUR vault pays out. An escrow route
|
|
233
|
+
// takes money IN from the buyer, so the ceiling does not apply to it — and
|
|
234
|
+
// applying it would refuse a perfectly good escrow price for no reason.
|
|
235
|
+
if (mode === "escrow") {
|
|
236
|
+
if (priceNum > maxPer) {
|
|
237
|
+
log(`note: --escrow price ${price} is above --max ${maxPer}. That is fine here — escrow money comes FROM the buyer, so your spending mandate never touches it.`);
|
|
238
|
+
}
|
|
224
239
|
}
|
|
225
|
-
|
|
226
|
-
|
|
240
|
+
else {
|
|
241
|
+
if (priceNum > maxPer) {
|
|
242
|
+
bad(`--price ${price} is above --max ${maxPer}, so every payment would be refused by your own mandate.\n raise --max to at least ${price} (for example --max ${price}) or lower --price.`);
|
|
243
|
+
}
|
|
244
|
+
if (priceNum > daily) {
|
|
245
|
+
bad(`--price ${price} is above --daily ${daily}: one payment would spend the whole day's budget and the second would be refused.\n set --daily to at least ${price}.`);
|
|
246
|
+
}
|
|
227
247
|
}
|
|
228
248
|
const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, { principal: pub(keys.principal), approver: keys.approver ? pub(keys.approver) : undefined, agent: pub(keys.agent) }, network);
|
|
229
249
|
if (data.token.code === "USDC")
|
|
@@ -302,16 +322,17 @@ async function sell() {
|
|
|
302
322
|
// --split is the honest mode: this process cannot approve anything.
|
|
303
323
|
approver: split ? undefined : keys.approver,
|
|
304
324
|
split,
|
|
325
|
+
agentSeed: keys.agent,
|
|
305
326
|
portalKey,
|
|
306
327
|
publicHost,
|
|
307
328
|
agentKeys: [pub(keys.agent)],
|
|
308
329
|
routes: [
|
|
309
|
-
{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600) },
|
|
330
|
+
{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600), releaseSeconds },
|
|
310
331
|
// --showcase adds a second priced route for the public page. Same price,
|
|
311
332
|
// same mandate, same desk: the showcase gets no special treatment at the
|
|
312
333
|
// spending door, it only gets its own hostname.
|
|
313
334
|
...(flag("showcase")
|
|
314
|
-
? [{ uri: DEMO_ROUTE, price, mode: mode, holdSeconds: Number(arg("hold") ?? 3600) }]
|
|
335
|
+
? [{ uri: DEMO_ROUTE, price, mode: mode, holdSeconds: Number(arg("hold") ?? 3600), releaseSeconds }]
|
|
315
336
|
: []),
|
|
316
337
|
],
|
|
317
338
|
// The banner prints these numbers. This line is what makes them true.
|
|
@@ -753,6 +774,129 @@ async function withdraw() {
|
|
|
753
774
|
console.log(`done. tx ${res?.hash}`);
|
|
754
775
|
console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
|
|
755
776
|
}
|
|
777
|
+
// ---------------------------------------------------------------------- escrow
|
|
778
|
+
/**
|
|
779
|
+
* The seller's view of every escrow sold through this gateway.
|
|
780
|
+
*
|
|
781
|
+
* `paydlux escrow` lists what is locked, who locked it, and what the ledger
|
|
782
|
+
* says about each one. `paydlux escrow --release ord_…` takes the money — which
|
|
783
|
+
* needs both human keys, so on a --split vault it prints the challenge for the
|
|
784
|
+
* phone and stops, exactly like the approval desk does.
|
|
785
|
+
*
|
|
786
|
+
* Nothing here can take money early. The release timer is enforced by the
|
|
787
|
+
* ledger, not by this command: asking before the timer gets a sentence saying
|
|
788
|
+
* how long is left, and the network would refuse the transaction anyway.
|
|
789
|
+
*/
|
|
790
|
+
async function escrow() {
|
|
791
|
+
const { data, path } = loadVault();
|
|
792
|
+
const network = (data.network ?? "testnet");
|
|
793
|
+
const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, {
|
|
794
|
+
principal: pub(data.keys.principal),
|
|
795
|
+
approver: data.keys.approver ? pub(data.keys.approver) : undefined,
|
|
796
|
+
agent: pub(data.keys.agent),
|
|
797
|
+
}, network);
|
|
798
|
+
if (data.token.code === "USDC")
|
|
799
|
+
vault.useUsdc();
|
|
800
|
+
const release = arg("release");
|
|
801
|
+
const refund = arg("refund");
|
|
802
|
+
if (refund) {
|
|
803
|
+
// The buyer's side, kept here so the protection is a command and not a
|
|
804
|
+
// paragraph. Needs the buyer's key, which this file does not hold unless
|
|
805
|
+
// the buyer is also the seller — so it reads it from the environment.
|
|
806
|
+
const seed = process.env.PAYDLUX_BUYER_SEED;
|
|
807
|
+
if (!seed)
|
|
808
|
+
bad("--refund returns the BUYER's money, so it needs the buyer's key.\n PAYDLUX_BUYER_SEED=S… paydlux escrow --refund <escrow id>");
|
|
809
|
+
const out = await refundEscrow(network, refund, Keypair.fromSecret(seed));
|
|
810
|
+
console.log(`refunded. the money is back in the buyer's account. tx ${out.txHash}`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Anything the ledger has locked for this vault, whether or not this process
|
|
814
|
+
// remembers quoting it. A seller who restarted still has to see money that is
|
|
815
|
+
// already locked, or it looks like nothing was ever ordered.
|
|
816
|
+
const live = await pendingEscrows(network, vault.address);
|
|
817
|
+
const now = Math.floor(Date.now() / 1000);
|
|
818
|
+
if (!release) {
|
|
819
|
+
if (!live.length) {
|
|
820
|
+
console.log(`\nno escrow is locked for ${vault.address} right now.\n`);
|
|
821
|
+
console.log(`sell against escrow with: paydlux sell --escrow --price 3.5 --release 3600\n`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
console.log(`\nescrow locked for ${vault.address} on ${network === "public" ? "MAINNET" : "testnet"}\n`);
|
|
825
|
+
for (const e of live) {
|
|
826
|
+
const left = e.releaseAt - now;
|
|
827
|
+
console.log(` ${e.memo || "(no order id)"} ${e.amount} ${e.asset === "native" ? "XLM" : e.asset.split(":")[0]}`);
|
|
828
|
+
console.log(` escrow ${e.balanceId}`);
|
|
829
|
+
console.log(` buyer ${e.buyer}`);
|
|
830
|
+
console.log(` ${left > 0 ? `buyer can still take it back for ${Math.ceil(left / 60)} more min` : `release timer passed — claimable now with both keys`}`);
|
|
831
|
+
if (left <= 0)
|
|
832
|
+
console.log(` release: paydlux escrow --release ${e.memo || e.balanceId}`);
|
|
833
|
+
console.log("");
|
|
834
|
+
}
|
|
835
|
+
console.log(`an escrow with no order id you recognise is money locked for a deal you never quoted. Do not release it: refund it.\n`);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
// Match on order id first (what a human reads), then on balance id (what a
|
|
839
|
+
// script has). Both are exact; a prefix is not accepted, because two order
|
|
840
|
+
// ids can share a prefix and money cannot be released "approximately".
|
|
841
|
+
const hit = live.find((e) => e.memo === release || e.balanceId === release);
|
|
842
|
+
if (!hit)
|
|
843
|
+
bad(`nothing locked matches "${release}".\n run \`paydlux escrow\` to list what is locked, and use the order id or the escrow id exactly as printed.`);
|
|
844
|
+
if (now < hit.releaseAt) {
|
|
845
|
+
bad(`the release timer has not passed: ${Math.ceil((hit.releaseAt - now) / 60)} min to go.\n the buyer can still refund this money until then, so releasing it is not on the table.\n the ledger would refuse this anyway — this command only says so earlier and in words.`);
|
|
846
|
+
}
|
|
847
|
+
const keys = data.keys ?? {};
|
|
848
|
+
const agent = Keypair.fromSecret(keys.agent);
|
|
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
|
+
}
|
|
881
|
+
const challenge = await prepareEscrowRelease(network, hit.balanceId, vault.address, agent);
|
|
882
|
+
console.log(`\nthis laptop holds one of the two keys, so the release needs your phone.\n`);
|
|
883
|
+
console.log(describeChallenge(challenge));
|
|
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.
|
|
891
|
+
const file = path.replace(/\.json$/, ".escrow-release.json");
|
|
892
|
+
writeFileSync(file, JSON.stringify({ order: hit.memo || hit.balanceId, challenge }, null, 2), { mode: 0o600 });
|
|
893
|
+
console.log(`challenge also written to ${file} (0600) for the manual path.\n`);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const out = await claimEscrow(network, hit.balanceId, vault.address, { agent, approver: Keypair.fromSecret(keys.approver) });
|
|
897
|
+
console.log(`released ${hit.amount} for order ${hit.memo || "(no id)"}. tx ${out.txHash}`);
|
|
898
|
+
console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
|
|
899
|
+
}
|
|
756
900
|
// ----------------------------------------------------------------------- usdc
|
|
757
901
|
/**
|
|
758
902
|
* Let the vault and its payee hold real Circle USDC.
|
|
@@ -874,11 +1018,19 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
874
1018
|
paydlux setup [--mainnet] [--token USDC] [--fund-seed S…]
|
|
875
1019
|
create a vault. testnet by default (free, no wallet needed).
|
|
876
1020
|
paydlux sell [--price 1] [--route /api/data] [--max N] [--daily N]
|
|
877
|
-
[--split] [--tls] [--preauth]
|
|
1021
|
+
[--split] [--tls] [--preauth] [--escrow] [--release SECONDS]
|
|
878
1022
|
start the gateway and the approval desk.
|
|
879
1023
|
--max / --daily are your spending mandate: no single payment above --max,
|
|
880
1024
|
and nothing past --daily in a day. The desk refuses at the approve button,
|
|
881
1025
|
so a runaway agent cannot drain the vault even if you approve in error.
|
|
1026
|
+
--escrow sells the route against escrow instead: the buyer locks the money
|
|
1027
|
+
in a claimable balance you cannot touch until --release seconds have
|
|
1028
|
+
passed, and the buyer can take it back any time before then. You get paid
|
|
1029
|
+
by proving delivery before the timer, not by waiting for it.
|
|
1030
|
+
paydlux escrow [--release ORD | --refund ESCROW_ID]
|
|
1031
|
+
list what is locked for your vault, release one order, or take a buyer's
|
|
1032
|
+
money back. Release needs both human keys; on a --split vault it prints
|
|
1033
|
+
the challenge for your phone and stops there.
|
|
882
1034
|
paydlux donate [--file ./paydlux-vault.json] [--usdc] [--price 1] [--tls]
|
|
883
1035
|
a public door where other agents pay YOU from THEIR vault.
|
|
884
1036
|
--usdc prices the door in Circle USDC. It refuses to start unless the
|
|
@@ -1049,6 +1201,8 @@ export async function main() {
|
|
|
1049
1201
|
return withdraw();
|
|
1050
1202
|
if (cmd === "usdc")
|
|
1051
1203
|
return usdc();
|
|
1204
|
+
if (cmd === "escrow")
|
|
1205
|
+
return escrow();
|
|
1052
1206
|
bad(`unknown command "${cmd}".\n\n${HELP}`);
|
|
1053
1207
|
}
|
|
1054
1208
|
// Run only when this file is the entry point, so importing it in a test is safe.
|
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.
|