@paydlux/agent 0.1.20 → 0.1.21
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 +132 -8
- package/dist/escrow.d.ts +154 -0
- package/dist/escrow.js +325 -0
- package/dist/gateway.d.ts +95 -3
- package/dist/gateway.js +157 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/package.json +3 -2
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")
|
|
@@ -306,12 +326,12 @@ async function sell() {
|
|
|
306
326
|
publicHost,
|
|
307
327
|
agentKeys: [pub(keys.agent)],
|
|
308
328
|
routes: [
|
|
309
|
-
{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600) },
|
|
329
|
+
{ uri, price, mode, holdSeconds: Number(arg("hold") ?? 3600), releaseSeconds },
|
|
310
330
|
// --showcase adds a second priced route for the public page. Same price,
|
|
311
331
|
// same mandate, same desk: the showcase gets no special treatment at the
|
|
312
332
|
// spending door, it only gets its own hostname.
|
|
313
333
|
...(flag("showcase")
|
|
314
|
-
? [{ uri: DEMO_ROUTE, price, mode: mode, holdSeconds: Number(arg("hold") ?? 3600) }]
|
|
334
|
+
? [{ uri: DEMO_ROUTE, price, mode: mode, holdSeconds: Number(arg("hold") ?? 3600), releaseSeconds }]
|
|
315
335
|
: []),
|
|
316
336
|
],
|
|
317
337
|
// The banner prints these numbers. This line is what makes them true.
|
|
@@ -753,6 +773,100 @@ async function withdraw() {
|
|
|
753
773
|
console.log(`done. tx ${res?.hash}`);
|
|
754
774
|
console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
|
|
755
775
|
}
|
|
776
|
+
// ---------------------------------------------------------------------- escrow
|
|
777
|
+
/**
|
|
778
|
+
* The seller's view of every escrow sold through this gateway.
|
|
779
|
+
*
|
|
780
|
+
* `paydlux escrow` lists what is locked, who locked it, and what the ledger
|
|
781
|
+
* says about each one. `paydlux escrow --release ord_…` takes the money — which
|
|
782
|
+
* needs both human keys, so on a --split vault it prints the challenge for the
|
|
783
|
+
* phone and stops, exactly like the approval desk does.
|
|
784
|
+
*
|
|
785
|
+
* Nothing here can take money early. The release timer is enforced by the
|
|
786
|
+
* ledger, not by this command: asking before the timer gets a sentence saying
|
|
787
|
+
* how long is left, and the network would refuse the transaction anyway.
|
|
788
|
+
*/
|
|
789
|
+
async function escrow() {
|
|
790
|
+
const { data, path } = loadVault();
|
|
791
|
+
const network = (data.network ?? "testnet");
|
|
792
|
+
const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, {
|
|
793
|
+
principal: pub(data.keys.principal),
|
|
794
|
+
approver: data.keys.approver ? pub(data.keys.approver) : undefined,
|
|
795
|
+
agent: pub(data.keys.agent),
|
|
796
|
+
}, network);
|
|
797
|
+
if (data.token.code === "USDC")
|
|
798
|
+
vault.useUsdc();
|
|
799
|
+
const release = arg("release");
|
|
800
|
+
const refund = arg("refund");
|
|
801
|
+
if (refund) {
|
|
802
|
+
// The buyer's side, kept here so the protection is a command and not a
|
|
803
|
+
// paragraph. Needs the buyer's key, which this file does not hold unless
|
|
804
|
+
// the buyer is also the seller — so it reads it from the environment.
|
|
805
|
+
const seed = process.env.PAYDLUX_BUYER_SEED;
|
|
806
|
+
if (!seed)
|
|
807
|
+
bad("--refund returns the BUYER's money, so it needs the buyer's key.\n PAYDLUX_BUYER_SEED=S… paydlux escrow --refund <escrow id>");
|
|
808
|
+
const out = await refundEscrow(network, refund, Keypair.fromSecret(seed));
|
|
809
|
+
console.log(`refunded. the money is back in the buyer's account. tx ${out.txHash}`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
// Anything the ledger has locked for this vault, whether or not this process
|
|
813
|
+
// remembers quoting it. A seller who restarted still has to see money that is
|
|
814
|
+
// already locked, or it looks like nothing was ever ordered.
|
|
815
|
+
const live = await pendingEscrows(network, vault.address);
|
|
816
|
+
const now = Math.floor(Date.now() / 1000);
|
|
817
|
+
if (!release) {
|
|
818
|
+
if (!live.length) {
|
|
819
|
+
console.log(`\nno escrow is locked for ${vault.address} right now.\n`);
|
|
820
|
+
console.log(`sell against escrow with: paydlux sell --escrow --price 3.5 --release 3600\n`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
console.log(`\nescrow locked for ${vault.address} on ${network === "public" ? "MAINNET" : "testnet"}\n`);
|
|
824
|
+
for (const e of live) {
|
|
825
|
+
const left = e.releaseAt - now;
|
|
826
|
+
console.log(` ${e.memo || "(no order id)"} ${e.amount} ${e.asset === "native" ? "XLM" : e.asset.split(":")[0]}`);
|
|
827
|
+
console.log(` escrow ${e.balanceId}`);
|
|
828
|
+
console.log(` buyer ${e.buyer}`);
|
|
829
|
+
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`}`);
|
|
830
|
+
if (left <= 0)
|
|
831
|
+
console.log(` release: paydlux escrow --release ${e.memo || e.balanceId}`);
|
|
832
|
+
console.log("");
|
|
833
|
+
}
|
|
834
|
+
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`);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// Match on order id first (what a human reads), then on balance id (what a
|
|
838
|
+
// script has). Both are exact; a prefix is not accepted, because two order
|
|
839
|
+
// ids can share a prefix and money cannot be released "approximately".
|
|
840
|
+
const hit = live.find((e) => e.memo === release || e.balanceId === release);
|
|
841
|
+
if (!hit)
|
|
842
|
+
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.`);
|
|
843
|
+
if (now < hit.releaseAt) {
|
|
844
|
+
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.`);
|
|
845
|
+
}
|
|
846
|
+
const keys = data.keys ?? {};
|
|
847
|
+
const agent = Keypair.fromSecret(keys.agent);
|
|
848
|
+
if (!keys.approver) {
|
|
849
|
+
const challenge = await prepareEscrowRelease(network, hit.balanceId, vault.address, agent);
|
|
850
|
+
console.log(`\nthis laptop holds one of the two keys, so the release needs your phone.\n`);
|
|
851
|
+
console.log(describeChallenge(challenge));
|
|
852
|
+
console.log(`\n open: ${flag("tls") ? `https://${PUBLIC_HOST}` : "http://127.0.0.1:4022"}/?k=${data.desk?.portalKey ?? "…"}`);
|
|
853
|
+
console.log(` the order id is ${hit.memo || hit.balanceId} — sign only if you delivered it.\n`);
|
|
854
|
+
// Written out so the second signature can be added by whatever holds the
|
|
855
|
+
// approver key, without retyping XDR. Said exactly, because a promise here
|
|
856
|
+
// that nothing implements is a lie in the seller's terminal: no process
|
|
857
|
+
// reads this file back on its own.
|
|
858
|
+
const file = path.replace(/\.json$/, ".escrow-release.json");
|
|
859
|
+
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`);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const out = await claimEscrow(network, hit.balanceId, vault.address, { agent, approver: Keypair.fromSecret(keys.approver) });
|
|
867
|
+
console.log(`released ${hit.amount} for order ${hit.memo || "(no id)"}. tx ${out.txHash}`);
|
|
868
|
+
console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
|
|
869
|
+
}
|
|
756
870
|
// ----------------------------------------------------------------------- usdc
|
|
757
871
|
/**
|
|
758
872
|
* Let the vault and its payee hold real Circle USDC.
|
|
@@ -874,11 +988,19 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
874
988
|
paydlux setup [--mainnet] [--token USDC] [--fund-seed S…]
|
|
875
989
|
create a vault. testnet by default (free, no wallet needed).
|
|
876
990
|
paydlux sell [--price 1] [--route /api/data] [--max N] [--daily N]
|
|
877
|
-
[--split] [--tls] [--preauth]
|
|
991
|
+
[--split] [--tls] [--preauth] [--escrow] [--release SECONDS]
|
|
878
992
|
start the gateway and the approval desk.
|
|
879
993
|
--max / --daily are your spending mandate: no single payment above --max,
|
|
880
994
|
and nothing past --daily in a day. The desk refuses at the approve button,
|
|
881
995
|
so a runaway agent cannot drain the vault even if you approve in error.
|
|
996
|
+
--escrow sells the route against escrow instead: the buyer locks the money
|
|
997
|
+
in a claimable balance you cannot touch until --release seconds have
|
|
998
|
+
passed, and the buyer can take it back any time before then. You get paid
|
|
999
|
+
by proving delivery before the timer, not by waiting for it.
|
|
1000
|
+
paydlux escrow [--release ORD | --refund ESCROW_ID]
|
|
1001
|
+
list what is locked for your vault, release one order, or take a buyer's
|
|
1002
|
+
money back. Release needs both human keys; on a --split vault it prints
|
|
1003
|
+
the challenge for your phone and stops there.
|
|
882
1004
|
paydlux donate [--file ./paydlux-vault.json] [--usdc] [--price 1] [--tls]
|
|
883
1005
|
a public door where other agents pay YOU from THEIR vault.
|
|
884
1006
|
--usdc prices the door in Circle USDC. It refuses to start unless the
|
|
@@ -1049,6 +1171,8 @@ export async function main() {
|
|
|
1049
1171
|
return withdraw();
|
|
1050
1172
|
if (cmd === "usdc")
|
|
1051
1173
|
return usdc();
|
|
1174
|
+
if (cmd === "escrow")
|
|
1175
|
+
return escrow();
|
|
1052
1176
|
bad(`unknown command "${cmd}".\n\n${HELP}`);
|
|
1053
1177
|
}
|
|
1054
1178
|
// Run only when this file is the entry point, so importing it in a test is safe.
|
package/dist/escrow.d.ts
ADDED
|
@@ -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
|
+
}>;
|
package/dist/escrow.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Paydlux escrow — a payment the buyer locks, not a payment the seller holds.
|
|
2
|
+
//
|
|
3
|
+
// The claim on the website is: "the escrow that holds a payment until delivery
|
|
4
|
+
// is proven". This file is what makes that claim true, and it deliberately does
|
|
5
|
+
// NOT use a smart contract. Stellar has a native primitive for exactly this
|
|
6
|
+
// shape of deal — a claimable balance with two claimants and a time predicate —
|
|
7
|
+
// and it is strictly better than a contract for a payment rail:
|
|
8
|
+
//
|
|
9
|
+
// * no contract to audit, and no contract address that can be wrong
|
|
10
|
+
// * the money never sits in an account the seller controls
|
|
11
|
+
// * the reserve is paid by whoever creates the balance (the buyer), and the
|
|
12
|
+
// ledger gives it back when the balance is claimed
|
|
13
|
+
// * the rules are enforced by the network, not by our server being up
|
|
14
|
+
//
|
|
15
|
+
// The shape, verified against testnet (see stellar/escrow-check.ts):
|
|
16
|
+
//
|
|
17
|
+
// claimant 1 — the seller vault, predicate NOT(abs_before = releaseAt)
|
|
18
|
+
// so the vault can only claim AFTER the release time
|
|
19
|
+
// claimant 2 — the buyer, predicate abs_before = releaseAt
|
|
20
|
+
// so the buyer can claw the money back ONLY before it
|
|
21
|
+
//
|
|
22
|
+
// Three properties follow, and all three were observed on the ledger:
|
|
23
|
+
// 1. before releaseAt the vault cannot take the money, even with both of its
|
|
24
|
+
// keys (tx_failed on the claim)
|
|
25
|
+
// 2. after releaseAt the vault can take it, but only with BOTH keys — one
|
|
26
|
+
// signature is tx_bad_auth, so a stolen agent key cannot empty an escrow
|
|
27
|
+
// 3. before releaseAt the buyer can always take it back, no seller consent
|
|
28
|
+
//
|
|
29
|
+
// So the seller's incentive is to prove delivery before the timer: releasing
|
|
30
|
+
// early needs the human approval key anyway, and until then the money is the
|
|
31
|
+
// buyer's to reclaim. That is the whole product in two claimants.
|
|
32
|
+
import { Asset, Claimant, Horizon, Memo, Operation, TransactionBuilder, getClaimableBalanceIdFromResult, xdr, } from "@stellar/stellar-sdk";
|
|
33
|
+
import { passphrase } from "./vault.js";
|
|
34
|
+
const HORIZON_URL = {
|
|
35
|
+
testnet: "https://horizon-testnet.stellar.org",
|
|
36
|
+
public: "https://horizon.stellar.org",
|
|
37
|
+
};
|
|
38
|
+
export const horizonUrl = (network) => HORIZON_URL[network];
|
|
39
|
+
/** Horizon writes an asset as "native" or "USDC:Gissuer…". */
|
|
40
|
+
export function assetToHorizonString(asset) {
|
|
41
|
+
return asset.getAssetType() === "native"
|
|
42
|
+
? "native"
|
|
43
|
+
: `${asset.getCode()}:${asset.getIssuer()}`;
|
|
44
|
+
}
|
|
45
|
+
export function assetFromHorizonString(s) {
|
|
46
|
+
if (s === "native")
|
|
47
|
+
return Asset.native();
|
|
48
|
+
const [code, issuer] = String(s).split(":");
|
|
49
|
+
if (!code || !issuer)
|
|
50
|
+
throw new Error(`unrecognised asset string "${s}"`);
|
|
51
|
+
return new Asset(code, issuer);
|
|
52
|
+
}
|
|
53
|
+
async function getJson(url) {
|
|
54
|
+
const r = await fetch(url);
|
|
55
|
+
if (!r.ok) {
|
|
56
|
+
const e = new Error(`horizon ${r.status} on ${url.split("?")[0]}`);
|
|
57
|
+
e.status = r.status;
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
return r.json();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read a claimable balance back from the ledger and turn it into an Escrow.
|
|
64
|
+
*
|
|
65
|
+
* Horizon does NOT put the memo on the balance record — the memo lives on the
|
|
66
|
+
* transaction that created it. So the join between "this pile of money" and
|
|
67
|
+
* "that order" is one extra request. It is not optional: without it, two orders
|
|
68
|
+
* of the same price to the same vault are indistinguishable, and a seller could
|
|
69
|
+
* deliver order A and claim the escrow that was locked for order B.
|
|
70
|
+
*/
|
|
71
|
+
export async function readEscrow(network, balanceId) {
|
|
72
|
+
const h = horizonUrl(network);
|
|
73
|
+
let cb;
|
|
74
|
+
try {
|
|
75
|
+
cb = await getJson(`${h}/claimable_balances/${balanceId}`);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (e.status === 404 || e.status === 400)
|
|
79
|
+
return null;
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
if (!cb?.claimants)
|
|
83
|
+
return null;
|
|
84
|
+
// The two claimants are distinguished by their predicate, not by position:
|
|
85
|
+
// the seller's is wrapped in `not`, the buyer's is the bare abs_before.
|
|
86
|
+
const seller = cb.claimants.find((c) => c.predicate?.not);
|
|
87
|
+
const buyer = cb.claimants.find((c) => !c.predicate?.not && c.predicate?.abs_before_epoch);
|
|
88
|
+
if (!seller || !buyer)
|
|
89
|
+
return null;
|
|
90
|
+
const txs = await getJson(`${h}/claimable_balances/${balanceId}/transactions?order=asc&limit=1`);
|
|
91
|
+
const tx = txs?._embedded?.records?.[0];
|
|
92
|
+
return {
|
|
93
|
+
balanceId,
|
|
94
|
+
amount: cb.amount,
|
|
95
|
+
asset: cb.asset,
|
|
96
|
+
vault: seller.destination,
|
|
97
|
+
buyer: buyer.destination,
|
|
98
|
+
releaseAt: Number(seller.predicate.not.abs_before_epoch),
|
|
99
|
+
txHash: tx?.hash ?? "",
|
|
100
|
+
memo: tx?.memo ?? "",
|
|
101
|
+
memoType: tx?.memo_type ?? "none",
|
|
102
|
+
claimed: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Does this on-chain escrow actually pay THIS deal?
|
|
107
|
+
*
|
|
108
|
+
* Every field is checked against the terms the gateway issued, because the
|
|
109
|
+
* buyer is untrusted: a buyer could lock 0.0000001 XLM, or lock money to a
|
|
110
|
+
* different vault, or set the timer to next year, or reuse the escrow from a
|
|
111
|
+
* previous order. Anything short of an exact match is a refusal.
|
|
112
|
+
*/
|
|
113
|
+
export async function verifyEscrow(network, balanceId, want, opts = {}) {
|
|
114
|
+
const e = await readEscrow(network, balanceId);
|
|
115
|
+
if (!e)
|
|
116
|
+
return { ok: false, reason: "no such claimable balance (already claimed, or never created)" };
|
|
117
|
+
if (e.asset !== assetToHorizonString(want.asset))
|
|
118
|
+
return { ok: false, reason: `escrow holds ${e.asset}, this route is priced in ${assetToHorizonString(want.asset)}` };
|
|
119
|
+
if (Number(e.amount) !== Number(want.amount))
|
|
120
|
+
return { ok: false, reason: `escrow holds ${e.amount}, this route costs ${want.amount}` };
|
|
121
|
+
if (e.vault !== want.vault)
|
|
122
|
+
return { ok: false, reason: "escrow names a different seller vault" };
|
|
123
|
+
if (want.buyer && e.buyer !== want.buyer)
|
|
124
|
+
return { ok: false, reason: "escrow was locked by a different account" };
|
|
125
|
+
// The memo is the only link between money and order. Exact match, or no deal.
|
|
126
|
+
if (e.memoType !== "text" || e.memo !== want.orderId)
|
|
127
|
+
return { ok: false, reason: `escrow memo "${e.memo}" does not match order ${want.orderId}` };
|
|
128
|
+
const now = Math.floor(Date.now() / 1000);
|
|
129
|
+
if (e.releaseAt <= now && !opts.allowClosedWindow) {
|
|
130
|
+
return { ok: false, reason: "the refund window has already closed — the seller could already have claimed it" };
|
|
131
|
+
}
|
|
132
|
+
if (opts.minReleaseSeconds != null && e.releaseAt - now < opts.minReleaseSeconds)
|
|
133
|
+
return { ok: false, reason: "release timer is too soon to prove delivery" };
|
|
134
|
+
if (opts.maxReleaseSeconds != null && e.releaseAt - now > opts.maxReleaseSeconds)
|
|
135
|
+
return { ok: false, reason: "release timer is further out than this route allows" };
|
|
136
|
+
return { ok: true, escrow: e };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The balance id of the escrow a submitted transaction created.
|
|
140
|
+
*
|
|
141
|
+
* Horizon does not return this to the submitter, and the id is not derivable
|
|
142
|
+
* from the account alone — it comes out of the transaction RESULT. A buyer who
|
|
143
|
+
* locks money with their own code needs this to know what to put in the
|
|
144
|
+
* `x-payment` header, so it is exported rather than kept private.
|
|
145
|
+
*/
|
|
146
|
+
export function escrowBalanceId(resultXdr, index = 0) {
|
|
147
|
+
return getClaimableBalanceIdFromResult(xdr.TransactionResult.fromXdr(resultXdr, "base64"), index);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Lock an escrow. Needs the BUYER's key: the money leaves the buyer's account,
|
|
151
|
+
* so only the buyer can put it in escrow. The seller never gets a signature on
|
|
152
|
+
* this transaction — that is the point.
|
|
153
|
+
*/
|
|
154
|
+
export async function createEscrow(network, terms, buyerKey) {
|
|
155
|
+
if (!terms.buyer)
|
|
156
|
+
throw new Error("creating an escrow needs terms.buyer — the money leaves that account");
|
|
157
|
+
if (buyerKey.publicKey() !== terms.buyer)
|
|
158
|
+
throw new Error("the signing key is not the escrow buyer");
|
|
159
|
+
const h = new Horizon.Server(horizonUrl(network));
|
|
160
|
+
const acc = await h.loadAccount(terms.buyer);
|
|
161
|
+
const releaseAt = Math.floor(terms.releaseAt);
|
|
162
|
+
const tx = new TransactionBuilder(acc, { fee: "100", networkPassphrase: passphrase(network) })
|
|
163
|
+
.addOperation(Operation.createClaimableBalance({
|
|
164
|
+
asset: terms.asset,
|
|
165
|
+
amount: terms.amount,
|
|
166
|
+
// Order matters for readability only; the predicates are what enforce the deal.
|
|
167
|
+
claimants: [
|
|
168
|
+
new Claimant(terms.vault, Claimant.predicateNot(Claimant.predicateBeforeAbsoluteTime(String(releaseAt)))),
|
|
169
|
+
new Claimant(terms.buyer, Claimant.predicateBeforeAbsoluteTime(String(releaseAt))),
|
|
170
|
+
],
|
|
171
|
+
}))
|
|
172
|
+
// The memo is the order id. Without it the escrow cannot be matched to an
|
|
173
|
+
// order, and verifyEscrow() will refuse it.
|
|
174
|
+
.addMemo(Memo.text(terms.orderId))
|
|
175
|
+
.setTimeout(300)
|
|
176
|
+
.build();
|
|
177
|
+
tx.sign(buyerKey);
|
|
178
|
+
const res = await h.submitTransaction(tx);
|
|
179
|
+
const balanceId = getClaimableBalanceIdFromResult(xdr.TransactionResult.fromXdr(res.result_xdr, "base64"), 0);
|
|
180
|
+
const escrow = await readEscrow(network, balanceId);
|
|
181
|
+
if (!escrow)
|
|
182
|
+
throw new Error("escrow was created but Horizon will not show it yet");
|
|
183
|
+
return { escrow, txHash: res.hash };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The seller takes the escrow. Two keys, always: the vault's threshold is 2 and
|
|
187
|
+
* the claim is a medium-threshold operation, so the agent key alone gets
|
|
188
|
+
* tx_bad_auth. `approverKey` lives on the phone in a real deployment.
|
|
189
|
+
*
|
|
190
|
+
* Refuses before the release time rather than letting Horizon reject it, so the
|
|
191
|
+
* caller gets a sentence instead of an error code — and so a buggy scheduler
|
|
192
|
+
* cannot accidentally try to release early in a loop.
|
|
193
|
+
*/
|
|
194
|
+
export async function claimEscrow(network, balanceId, vaultAddress, keys) {
|
|
195
|
+
const e = await readEscrow(network, balanceId);
|
|
196
|
+
if (!e)
|
|
197
|
+
throw new Error("no such escrow — it has already been claimed or refunded");
|
|
198
|
+
if (e.vault !== vaultAddress)
|
|
199
|
+
throw new Error(`escrow ${balanceId.slice(0, 12)}… pays a different vault`);
|
|
200
|
+
const now = Math.floor(Date.now() / 1000);
|
|
201
|
+
if (now < e.releaseAt)
|
|
202
|
+
throw new Error(`release timer has not passed (${e.releaseAt - now}s to go)`);
|
|
203
|
+
const h = new Horizon.Server(horizonUrl(network));
|
|
204
|
+
const acc = await h.loadAccount(vaultAddress);
|
|
205
|
+
const tx = new TransactionBuilder(acc, { fee: "100", networkPassphrase: passphrase(network) })
|
|
206
|
+
.addOperation(Operation.claimClaimableBalance({ balanceId }))
|
|
207
|
+
.setTimeout(120)
|
|
208
|
+
.build();
|
|
209
|
+
tx.sign(keys.agent);
|
|
210
|
+
tx.sign(keys.approver);
|
|
211
|
+
const res = await h.submitTransaction(tx);
|
|
212
|
+
return { txHash: res.hash, claimedAt: now };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Release an escrow with the approver's key on a PHONE.
|
|
216
|
+
*
|
|
217
|
+
* `claimEscrow` needs both keys in one process. In a real deployment the human
|
|
218
|
+
* half never lives on the seller's machine — it lives on their phone. So build
|
|
219
|
+
* the claim here, sign it with the agent key, and hand it out as the same
|
|
220
|
+
* challenge the payment desk already uses: the phone reads a sentence
|
|
221
|
+
* ("claim 3.5 XLM from escrow ord_…"), signs, and sends back XDR.
|
|
222
|
+
*
|
|
223
|
+
* The phone is the only place the second signature can be added. This process
|
|
224
|
+
* cannot sign for it, cannot submit a completed claim, and cannot do anything
|
|
225
|
+
* with the half-signed transaction except wait.
|
|
226
|
+
*/
|
|
227
|
+
export async function prepareEscrowRelease(network, balanceId, vaultAddress, agent) {
|
|
228
|
+
const e = await readEscrow(network, balanceId);
|
|
229
|
+
if (!e)
|
|
230
|
+
throw new Error("no such escrow — it has already been claimed or refunded");
|
|
231
|
+
if (e.vault !== vaultAddress)
|
|
232
|
+
throw new Error(`escrow ${balanceId.slice(0, 12)}… pays a different vault`);
|
|
233
|
+
const now = Math.floor(Date.now() / 1000);
|
|
234
|
+
if (now < e.releaseAt)
|
|
235
|
+
throw new Error(`release timer has not passed (${e.releaseAt - now}s to go)`);
|
|
236
|
+
const h = new Horizon.Server(horizonUrl(network));
|
|
237
|
+
const acc = await h.loadAccount(vaultAddress);
|
|
238
|
+
const tx = new TransactionBuilder(acc, { fee: "100", networkPassphrase: passphrase(network) })
|
|
239
|
+
.addOperation(Operation.claimClaimableBalance({ balanceId }))
|
|
240
|
+
.addMemo(Memo.text(`release ${e.memo}`))
|
|
241
|
+
.setTimeout(1800)
|
|
242
|
+
.build();
|
|
243
|
+
tx.sign(agent);
|
|
244
|
+
return {
|
|
245
|
+
id: `escrow:${balanceId}`,
|
|
246
|
+
network,
|
|
247
|
+
xdr: tx.toXDR(),
|
|
248
|
+
summary: {
|
|
249
|
+
payee: vaultAddress,
|
|
250
|
+
amount: e.amount,
|
|
251
|
+
token: e.asset === "native" ? "XLM" : e.asset.split(":")[0],
|
|
252
|
+
tokenIssuer: e.asset === "native" ? "" : e.asset.split(":")[1] ?? "",
|
|
253
|
+
from: `escrow ${balanceId.slice(0, 16)}…`,
|
|
254
|
+
memo: `order ${e.memo} — release timer passed`,
|
|
255
|
+
expiresAt: Number(tx.timeBounds?.maxTime) || undefined,
|
|
256
|
+
},
|
|
257
|
+
hash: Buffer.from(tx.hash()).toString("hex"),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The buyer takes their money back. No seller signature, no permission, and it
|
|
262
|
+
* works right up to the release second — which is why the seller has to earn
|
|
263
|
+
* the release by proving delivery instead of waiting for the timer.
|
|
264
|
+
*/
|
|
265
|
+
export async function refundEscrow(network, balanceId, buyerKey) {
|
|
266
|
+
const e = await readEscrow(network, balanceId);
|
|
267
|
+
if (!e)
|
|
268
|
+
throw new Error("no such escrow — it has already been claimed or refunded");
|
|
269
|
+
if (e.buyer !== buyerKey.publicKey())
|
|
270
|
+
throw new Error("only the account that locked this escrow can refund it");
|
|
271
|
+
const now = Math.floor(Date.now() / 1000);
|
|
272
|
+
if (now >= e.releaseAt)
|
|
273
|
+
throw new Error("the refund window has closed");
|
|
274
|
+
const h = new Horizon.Server(horizonUrl(network));
|
|
275
|
+
const acc = await h.loadAccount(buyerKey.publicKey());
|
|
276
|
+
const tx = new TransactionBuilder(acc, { fee: "100", networkPassphrase: passphrase(network) })
|
|
277
|
+
.addOperation(Operation.claimClaimableBalance({ balanceId }))
|
|
278
|
+
.setTimeout(120)
|
|
279
|
+
.build();
|
|
280
|
+
tx.sign(buyerKey);
|
|
281
|
+
const res = await h.submitTransaction(tx);
|
|
282
|
+
return { txHash: res.hash };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Every escrow currently waiting on this vault, newest first.
|
|
286
|
+
*
|
|
287
|
+
* Horizon filters claimable balances by claimant, which is exactly the seller's
|
|
288
|
+
* question: "what money is sitting in escrow for me, and when does each one
|
|
289
|
+
* become mine?" A balance that has already been claimed disappears from this
|
|
290
|
+
* list, so it doubles as the pending-release queue.
|
|
291
|
+
*/
|
|
292
|
+
export async function pendingEscrows(network, vaultAddress, limit = 200) {
|
|
293
|
+
const h = horizonUrl(network);
|
|
294
|
+
const list = await getJson(`${h}/claimable_balances?claimant=${vaultAddress}&order=desc&limit=${Math.min(limit, 200)}`);
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const rec of list?._embedded?.records ?? []) {
|
|
297
|
+
const e = await readEscrow(network, rec.id);
|
|
298
|
+
if (e)
|
|
299
|
+
out.push(e);
|
|
300
|
+
}
|
|
301
|
+
return out.sort((a, b) => a.releaseAt - b.releaseAt);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Has this escrow been settled, and by whom?
|
|
305
|
+
*
|
|
306
|
+
* A claimed balance vanishes from Horizon, so the answer is in the operations
|
|
307
|
+
* of the transaction that claimed it. This is the proof-of-delivery receipt:
|
|
308
|
+
* the buyer can look up the same number on any explorer.
|
|
309
|
+
*/
|
|
310
|
+
export async function escrowOutcome(network, balanceId) {
|
|
311
|
+
const h = horizonUrl(network);
|
|
312
|
+
let ops;
|
|
313
|
+
try {
|
|
314
|
+
ops = await getJson(`${h}/claimable_balances/${balanceId}/operations?order=desc&limit=5`);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
if (e.status === 404 || e.status === 400)
|
|
318
|
+
return { settled: false };
|
|
319
|
+
throw e;
|
|
320
|
+
}
|
|
321
|
+
const claim = (ops?._embedded?.records ?? []).find((o) => o.type === "claim_claimable_balance");
|
|
322
|
+
if (!claim)
|
|
323
|
+
return { settled: false };
|
|
324
|
+
return { settled: true, by: claim.account ?? claim.source_account, txHash: claim.transaction_hash, at: claim.created_at };
|
|
325
|
+
}
|
package/dist/gateway.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
-
import { Transaction } from "@stellar/stellar-sdk";
|
|
2
|
+
import { Keypair, Transaction } from "@stellar/stellar-sdk";
|
|
3
3
|
import { StellarVault, StellarTxError, type Amount, type Mandate, type Network } from "./vault.ts";
|
|
4
4
|
import { ApprovalDesk, type DeskStore } from "./desk.ts";
|
|
5
5
|
import type { PolicyEngine } from "./policy.ts";
|
|
6
|
+
import { claimEscrow } from "./escrow.ts";
|
|
6
7
|
export declare const PAY_HEADER = "x-payment";
|
|
7
8
|
export type Route = {
|
|
8
9
|
uri: string;
|
|
9
10
|
price: Amount;
|
|
10
|
-
mode: "preauth" | "approval";
|
|
11
|
+
mode: "preauth" | "approval" | "escrow";
|
|
11
12
|
/** seconds the seller's content stays reserved while a human decides */
|
|
12
13
|
holdSeconds?: number;
|
|
13
14
|
/**
|
|
@@ -15,6 +16,12 @@ export type Route = {
|
|
|
15
16
|
* holdSeconds. Set it to 0 to make every single call cost its own approval.
|
|
16
17
|
*/
|
|
17
18
|
collectSeconds?: number;
|
|
19
|
+
/**
|
|
20
|
+
* escrow mode: how long the buyer keeps the right to take their money back.
|
|
21
|
+
* The seller cannot touch the money before this passes, so it is also the
|
|
22
|
+
* deadline for proving delivery. Default 3600.
|
|
23
|
+
*/
|
|
24
|
+
releaseSeconds?: number;
|
|
18
25
|
};
|
|
19
26
|
export type GatewayConfig = {
|
|
20
27
|
vault: StellarVault;
|
|
@@ -69,6 +76,12 @@ export type PaymentProof = {
|
|
|
69
76
|
payload: string;
|
|
70
77
|
signature: string;
|
|
71
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* escrow mode: the id of the claimable balance the BUYER locked for this
|
|
81
|
+
* order. The money is not the seller's yet — it becomes claimable after the
|
|
82
|
+
* release timer, and only with both human keys.
|
|
83
|
+
*/
|
|
84
|
+
escrow?: string;
|
|
72
85
|
};
|
|
73
86
|
export declare class Gateway {
|
|
74
87
|
private routes;
|
|
@@ -80,6 +93,66 @@ export declare class Gateway {
|
|
|
80
93
|
constructor(cfg: GatewayConfig);
|
|
81
94
|
/** Mandates the vault currently holds, so the gateway can hand them out. */
|
|
82
95
|
syncMandates(): Promise<any>;
|
|
96
|
+
/**
|
|
97
|
+
* Escrow orders this gateway has issued, keyed by order id.
|
|
98
|
+
*
|
|
99
|
+
* The order id is the ONLY link between a pile of money on the ledger and a
|
|
100
|
+
* piece of content behind a route: Horizon does not put a memo on the balance
|
|
101
|
+
* record, so matching money to a deal means reading the memo off the creating
|
|
102
|
+
* transaction and comparing it to an order we recognise. An escrow whose memo
|
|
103
|
+
* matches nothing here is money locked for a deal this seller never quoted, so
|
|
104
|
+
* it buys nothing.
|
|
105
|
+
*/
|
|
106
|
+
private orders;
|
|
107
|
+
/**
|
|
108
|
+
* A stable order id for one request. Deterministic on purpose: a buyer who
|
|
109
|
+
* retries while the escrow settles must land on the same order, and a seller
|
|
110
|
+
* who restarts must be able to recognise the memo on money already locked.
|
|
111
|
+
* It is not a secret and it is not a capability — holding an id buys nothing
|
|
112
|
+
* without the matching escrow on the ledger.
|
|
113
|
+
*/
|
|
114
|
+
orderId(uri: string, price: Amount, body: string): string;
|
|
115
|
+
/** Escrow orders, oldest first, with whatever the ledger says about each. */
|
|
116
|
+
escrowOrders(opts?: {
|
|
117
|
+
includeSettled?: boolean;
|
|
118
|
+
}): Promise<{
|
|
119
|
+
id: string;
|
|
120
|
+
uri: string;
|
|
121
|
+
price: Amount;
|
|
122
|
+
at: number;
|
|
123
|
+
escrowId?: string;
|
|
124
|
+
releaseAt?: number;
|
|
125
|
+
served: number;
|
|
126
|
+
state: string;
|
|
127
|
+
escrow: import("./escrow.ts").Escrow | null;
|
|
128
|
+
outcome: {
|
|
129
|
+
settled: boolean;
|
|
130
|
+
by?: string;
|
|
131
|
+
txHash?: string;
|
|
132
|
+
at?: string;
|
|
133
|
+
};
|
|
134
|
+
}[]>;
|
|
135
|
+
/**
|
|
136
|
+
* Take the money the buyer locked. Two keys, always — the vault's threshold
|
|
137
|
+
* is 2, so a stolen agent key cannot empty an escrow on its own.
|
|
138
|
+
*/
|
|
139
|
+
releaseEscrow(orderId: string, keys: {
|
|
140
|
+
agent: Parameters<typeof claimEscrow>[3]["agent"];
|
|
141
|
+
approver: Parameters<typeof claimEscrow>[3]["approver"];
|
|
142
|
+
}): Promise<{
|
|
143
|
+
txHash: string;
|
|
144
|
+
claimedAt: number;
|
|
145
|
+
}>;
|
|
146
|
+
/**
|
|
147
|
+
* The split-key release: build the claim, sign it with the agent key, and
|
|
148
|
+
* hand it to the phone. This process cannot finish it — that is the point.
|
|
149
|
+
*/
|
|
150
|
+
escrowReleaseChallenge(orderId: string, agent: Keypair): Promise<import("./phone.ts").ApprovalChallenge>;
|
|
151
|
+
/** Take the phone's signature back and settle the escrow on the ledger. */
|
|
152
|
+
submitEscrowRelease(signedXdr: string, orderId: string): Promise<{
|
|
153
|
+
hash: string;
|
|
154
|
+
ok: true;
|
|
155
|
+
}>;
|
|
83
156
|
/** Register a pre-authorised mandate for a route (needs both human keys). */
|
|
84
157
|
authorise(uri: string, spec: Parameters<StellarVault["createMandate"]>[0], principal: Parameters<StellarVault["createMandate"]>[1], approver: Parameters<StellarVault["createMandate"]>[2]): Promise<Mandate>;
|
|
85
158
|
/**
|
|
@@ -104,9 +177,28 @@ export declare class Gateway {
|
|
|
104
177
|
issuer: string | undefined;
|
|
105
178
|
};
|
|
106
179
|
vault: string;
|
|
107
|
-
mode: "approval" | "preauth";
|
|
180
|
+
mode: "approval" | "escrow" | "preauth";
|
|
181
|
+
maxTimeoutSeconds: number;
|
|
182
|
+
version: string;
|
|
183
|
+
} | {
|
|
184
|
+
scheme: string;
|
|
185
|
+
network: string;
|
|
186
|
+
maxAmountRequired: string;
|
|
187
|
+
resource: string;
|
|
188
|
+
payTo: string;
|
|
189
|
+
token: {
|
|
190
|
+
code: string;
|
|
191
|
+
issuer: string | undefined;
|
|
192
|
+
};
|
|
193
|
+
vault: string;
|
|
194
|
+
mode: "approval" | "escrow" | "preauth";
|
|
108
195
|
maxTimeoutSeconds: number;
|
|
109
196
|
version: string;
|
|
197
|
+
escrow: {
|
|
198
|
+
vault: string;
|
|
199
|
+
releaseSeconds: number;
|
|
200
|
+
memoMustBe: string;
|
|
201
|
+
};
|
|
110
202
|
};
|
|
111
203
|
/**
|
|
112
204
|
* Check a proof against the live request before spending anything.
|
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
|
-
//
|
|
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();
|
|
@@ -48,6 +55,75 @@ export class Gateway {
|
|
|
48
55
|
const s = await this.cfg.vault.signerSummary();
|
|
49
56
|
return s.signers.filter((x) => x.type === "preauth-mandate");
|
|
50
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Escrow orders this gateway has issued, keyed by order id.
|
|
60
|
+
*
|
|
61
|
+
* The order id is the ONLY link between a pile of money on the ledger and a
|
|
62
|
+
* piece of content behind a route: Horizon does not put a memo on the balance
|
|
63
|
+
* record, so matching money to a deal means reading the memo off the creating
|
|
64
|
+
* transaction and comparing it to an order we recognise. An escrow whose memo
|
|
65
|
+
* matches nothing here is money locked for a deal this seller never quoted, so
|
|
66
|
+
* it buys nothing.
|
|
67
|
+
*/
|
|
68
|
+
orders = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* A stable order id for one request. Deterministic on purpose: a buyer who
|
|
71
|
+
* retries while the escrow settles must land on the same order, and a seller
|
|
72
|
+
* who restarts must be able to recognise the memo on money already locked.
|
|
73
|
+
* It is not a secret and it is not a capability — holding an id buys nothing
|
|
74
|
+
* without the matching escrow on the ledger.
|
|
75
|
+
*/
|
|
76
|
+
orderId(uri, price, body) {
|
|
77
|
+
return "ord_" + createHash("sha256")
|
|
78
|
+
.update(`${this.cfg.vault.address}|${uri}|${price}|${body}`).digest("hex").slice(0, 16);
|
|
79
|
+
}
|
|
80
|
+
/** Escrow orders, oldest first, with whatever the ledger says about each. */
|
|
81
|
+
async escrowOrders(opts = {}) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const o of this.orders.values()) {
|
|
84
|
+
const e = o.escrowId ? await readEscrow(this.cfg.vault.network, o.escrowId) : null;
|
|
85
|
+
const outcome = o.escrowId ? await escrowOutcome(this.cfg.vault.network, o.escrowId) : { settled: false };
|
|
86
|
+
const now = Math.floor(Date.now() / 1000);
|
|
87
|
+
const state = e
|
|
88
|
+
? (now >= e.releaseAt ? "releasable" : "locked")
|
|
89
|
+
: outcome.settled ? "released" : o.escrowId ? "gone" : "awaiting_escrow";
|
|
90
|
+
if (state === "released" && !opts.includeSettled)
|
|
91
|
+
continue;
|
|
92
|
+
out.push({ ...o, state, escrow: e, outcome });
|
|
93
|
+
}
|
|
94
|
+
return out.sort((a, b) => (a.releaseAt ?? 0) - (b.releaseAt ?? 0));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Take the money the buyer locked. Two keys, always — the vault's threshold
|
|
98
|
+
* is 2, so a stolen agent key cannot empty an escrow on its own.
|
|
99
|
+
*/
|
|
100
|
+
async releaseEscrow(orderId, keys) {
|
|
101
|
+
const o = this.orders.get(orderId);
|
|
102
|
+
if (!o?.escrowId)
|
|
103
|
+
throw new Error(`no escrow locked for order ${orderId}`);
|
|
104
|
+
const out = await claimEscrow(this.cfg.vault.network, o.escrowId, this.cfg.vault.address, keys);
|
|
105
|
+
this.settled.add("escrow:" + o.escrowId);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The split-key release: build the claim, sign it with the agent key, and
|
|
110
|
+
* hand it to the phone. This process cannot finish it — that is the point.
|
|
111
|
+
*/
|
|
112
|
+
async escrowReleaseChallenge(orderId, agent) {
|
|
113
|
+
const o = this.orders.get(orderId);
|
|
114
|
+
if (!o?.escrowId)
|
|
115
|
+
throw new Error(`no escrow locked for order ${orderId}`);
|
|
116
|
+
return prepareEscrowRelease(this.cfg.vault.network, o.escrowId, this.cfg.vault.address, agent);
|
|
117
|
+
}
|
|
118
|
+
/** Take the phone's signature back and settle the escrow on the ledger. */
|
|
119
|
+
async submitEscrowRelease(signedXdr, orderId) {
|
|
120
|
+
const o = this.orders.get(orderId);
|
|
121
|
+
if (!o?.escrowId)
|
|
122
|
+
throw new Error(`no escrow locked for order ${orderId}`);
|
|
123
|
+
const out = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
|
|
124
|
+
this.settled.add("escrow:" + o.escrowId);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
51
127
|
/** Register a pre-authorised mandate for a route (needs both human keys). */
|
|
52
128
|
async authorise(uri, spec, principal, approver) {
|
|
53
129
|
const r = this.routes.get(uri);
|
|
@@ -101,8 +177,8 @@ export class Gateway {
|
|
|
101
177
|
agentKeys;
|
|
102
178
|
requirements(uri) {
|
|
103
179
|
const r = this.routes.get(uri);
|
|
104
|
-
|
|
105
|
-
scheme: "stellar-exact",
|
|
180
|
+
const base = {
|
|
181
|
+
scheme: r.mode === "escrow" ? "stellar-escrow" : "stellar-exact",
|
|
106
182
|
network: `stellar:${this.cfg.network}`,
|
|
107
183
|
maxAmountRequired: r.price,
|
|
108
184
|
resource: uri,
|
|
@@ -113,6 +189,21 @@ export class Gateway {
|
|
|
113
189
|
maxTimeoutSeconds: r.holdSeconds ?? 3600,
|
|
114
190
|
version: "1",
|
|
115
191
|
};
|
|
192
|
+
if (r.mode !== "escrow")
|
|
193
|
+
return base;
|
|
194
|
+
// An escrow route tells the buyer how to lock the money, not just what to
|
|
195
|
+
// pay. Without the release window in the 402 the buyer cannot compute a
|
|
196
|
+
// valid timer, and the seller has no deadline to prove delivery against.
|
|
197
|
+
return {
|
|
198
|
+
...base,
|
|
199
|
+
escrow: {
|
|
200
|
+
vault: this.cfg.vault.address,
|
|
201
|
+
releaseSeconds: r.releaseSeconds ?? 3600,
|
|
202
|
+
// The memo on the creating transaction must be this order id, or the
|
|
203
|
+
// money cannot be matched to a deal and buys nothing.
|
|
204
|
+
memoMustBe: "the order id returned in this response",
|
|
205
|
+
},
|
|
206
|
+
};
|
|
116
207
|
}
|
|
117
208
|
/**
|
|
118
209
|
* Check a proof against the live request before spending anything.
|
|
@@ -193,8 +284,12 @@ export class Gateway {
|
|
|
193
284
|
const raw = req.headers[PAY_HEADER];
|
|
194
285
|
if (!raw) {
|
|
195
286
|
const reqs = this.requirements(uri);
|
|
196
|
-
|
|
197
|
-
|
|
287
|
+
// An escrow buyer has to know the order id BEFORE locking the money,
|
|
288
|
+
// because the memo on the creating transaction is the only thing that
|
|
289
|
+
// ties that pile of money to this deal. Hand it out in the 402.
|
|
290
|
+
const order = r.mode === "escrow" ? { order: this.orderId(uri, r.price, body) } : {};
|
|
291
|
+
res.writeHead(402, { [PAY_HEADER]: JSON.stringify({ ...reqs, ...order }) });
|
|
292
|
+
res.end(JSON.stringify({ error: "payment_required", accepts: reqs, ...order }));
|
|
198
293
|
return;
|
|
199
294
|
}
|
|
200
295
|
let proof;
|
|
@@ -205,8 +300,63 @@ export class Gateway {
|
|
|
205
300
|
res.writeHead(400).end("bad payment header");
|
|
206
301
|
return;
|
|
207
302
|
}
|
|
208
|
-
if (!proof.intent && !proof.envelope) {
|
|
209
|
-
res.writeHead(400).end("payment header needs an envelope or an
|
|
303
|
+
if (!proof.intent && !proof.envelope && !proof.escrow) {
|
|
304
|
+
res.writeHead(400).end("payment header needs an envelope, an intent, or an escrow id");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// escrow mode: the buyer locked the money in a claimable balance. The
|
|
308
|
+
// seller cannot claim it yet — that is the whole deal. Serving the body
|
|
309
|
+
// now means the buyer still holds the refund right, so the seller's
|
|
310
|
+
// incentive is to deliver first and release later.
|
|
311
|
+
if (r.mode === "escrow") {
|
|
312
|
+
if (!proof.escrow) {
|
|
313
|
+
res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
|
|
314
|
+
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 }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const id = this.orderId(uri, r.price, body);
|
|
318
|
+
const releaseSeconds = r.releaseSeconds ?? 3600;
|
|
319
|
+
const check = await verifyEscrow(this.cfg.vault.network, proof.escrow, {
|
|
320
|
+
orderId: id,
|
|
321
|
+
asset: this.cfg.vault.token,
|
|
322
|
+
amount: r.price,
|
|
323
|
+
vault: this.cfg.vault.address,
|
|
324
|
+
releaseAt: 0, // not compared; the window checks below do the work
|
|
325
|
+
}, { minReleaseSeconds: 1, maxReleaseSeconds: releaseSeconds + 300 });
|
|
326
|
+
if (!check.ok || !check.escrow) {
|
|
327
|
+
res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
|
|
328
|
+
res.end(JSON.stringify({ error: "escrow_invalid", order: id, reason: check.reason }));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const prior = this.orders.get(id);
|
|
332
|
+
this.orders.set(id, {
|
|
333
|
+
id, uri, price: r.price, at: prior?.at ?? Date.now(),
|
|
334
|
+
escrowId: proof.escrow, releaseAt: check.escrow.releaseAt,
|
|
335
|
+
served: (prior?.served ?? 0) + 1,
|
|
336
|
+
});
|
|
337
|
+
const content = getContent(uri);
|
|
338
|
+
res.writeHead(200, {
|
|
339
|
+
"content-type": "application/json",
|
|
340
|
+
"x-payment-response": JSON.stringify({
|
|
341
|
+
escrow: proof.escrow, order: id,
|
|
342
|
+
releaseAt: check.escrow.releaseAt,
|
|
343
|
+
lockTx: check.escrow.txHash,
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
res.end(JSON.stringify({
|
|
347
|
+
data: content,
|
|
348
|
+
order: id,
|
|
349
|
+
escrow: {
|
|
350
|
+
id: proof.escrow,
|
|
351
|
+
amount: check.escrow.amount,
|
|
352
|
+
releaseAt: check.escrow.releaseAt,
|
|
353
|
+
// The buyer's own refund right, spelled out so a client can act on
|
|
354
|
+
// it without reading a spec: claim the balance back before this
|
|
355
|
+
// second, no seller consent needed.
|
|
356
|
+
refundUntil: check.escrow.releaseAt,
|
|
357
|
+
lockTx: check.escrow.txHash,
|
|
358
|
+
},
|
|
359
|
+
}));
|
|
210
360
|
return;
|
|
211
361
|
}
|
|
212
362
|
// 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.
|
|
3
|
+
"version": "0.1.21",
|
|
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"
|
|
68
69
|
},
|
|
69
70
|
"keywords": [
|
|
70
71
|
"x402",
|