@paydlux/agent 0.1.19 → 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 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
@@ -24,9 +24,11 @@ import { StellarVault, USDC_ISSUER, passphrase } from "./vault.js";
24
24
  import { Gateway } from "./gateway.js";
25
25
  import { newPortalKey } from "./desk.js";
26
26
  import { PolicyEngine } from "./policy.js";
27
- import { PUBLIC_HOST, TUNNEL_NAME, runTunnel, tunnelLive, tunnelProblem, tunnelRunning, tunnelServes } from "./tunnel";
28
- import { DEMO_HOST, DEMO_ROUTE, startDemo } from "./showcase";
29
- import { DONATE_HOST, DONATE_PORT, startDonate } from "./donate";
27
+ import { PUBLIC_HOST, TUNNEL_NAME, runTunnel, tunnelLive, tunnelProblem, tunnelRunning, tunnelServes } from "./tunnel.js";
28
+ import { DEMO_HOST, DEMO_ROUTE, startDemo } from "./showcase.js";
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
- const mode = flag("preauth") ? "preauth" : "approval";
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
- if (priceNum > maxPer) {
223
- 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.`);
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
- if (priceNum > daily) {
226
- 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}.`);
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,26 @@ 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.
1004
+ paydlux donate [--file ./paydlux-vault.json] [--usdc] [--price 1] [--tls]
1005
+ a public door where other agents pay YOU from THEIR vault.
1006
+ --usdc prices the door in Circle USDC. It refuses to start unless the
1007
+ vault already holds a USDC trustline: without one every payment bounces
1008
+ with OP_NO_TRUST and the page looks broken forever.
1009
+ paydlux usdc [--file ./paydlux-vault.json]
1010
+ add that USDC trustline — one claimable, so the human half signs it.
882
1011
  paydlux status
883
1012
  balances, signers, live mandates, ledger rent.
884
1013
  paydlux approver [--forget]
@@ -908,6 +1037,22 @@ one file holds it all: ./paydlux-vault.json (0600, never commit it)
908
1037
  * seller's money. Fewer moving parts means less that can leak.
909
1038
  */
910
1039
  async function donate() {
1040
+ if (flag("help") || process.argv.includes("-h")) {
1041
+ console.log(`paydlux donate [--file ./paydlux-vault.json] [--usdc] [--price 1]
1042
+ [--tls] [--port ${DONATE_PORT}]
1043
+ a public door where other agents pay YOU from THEIR vault.
1044
+
1045
+ --usdc price the door in Circle USDC instead of the vault file's
1046
+ token. Refuses to start unless the vault already holds a
1047
+ USDC trustline (run: paydlux usdc) — without one every
1048
+ payment bounces with OP_NO_TRUST and the page looks broken.
1049
+ --price N what a donation costs, in the asset's own units.
1050
+ --tls required in practice: an order and its memo over plain HTTP
1051
+ can be taken by anyone on the path.
1052
+ --port N local port (default ${DONATE_PORT}); the public name is
1053
+ ${DONATE_HOST}, routed by the tunnel.`);
1054
+ return;
1055
+ }
911
1056
  const path = arg("file") ?? VAULT_FILE_DEFAULT;
912
1057
  if (!existsSync(path))
913
1058
  bad(`no vault file at ${path}. Run: paydlux setup`);
@@ -1026,6 +1171,8 @@ export async function main() {
1026
1171
  return withdraw();
1027
1172
  if (cmd === "usdc")
1028
1173
  return usdc();
1174
+ if (cmd === "escrow")
1175
+ return escrow();
1029
1176
  bad(`unknown command "${cmd}".\n\n${HELP}`);
1030
1177
  }
1031
1178
  // Run only when this file is the entry point, so importing it in a test is safe.
package/dist/donate.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Network } from "./receive";
1
+ import type { Network } from "./receive.ts";
2
2
  export declare const DONATE_HOST = "donate.paydlux.com";
3
3
  export declare const DONATE_PORT = 4024;
4
4
  export type DonatePageConfig = {
@@ -43,6 +43,6 @@ export declare function startDonate(cfg: {
43
43
  } | null>;
44
44
  }): {
45
45
  server: import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>;
46
- book: import("./receive").OrderBook;
46
+ book: import("./receive.ts").OrderBook;
47
47
  listen: (port: number) => Promise<void>;
48
48
  };
package/dist/donate.js CHANGED
@@ -13,8 +13,8 @@
13
13
  //
14
14
  // Nothing here holds a buyer key. We publish terms; they sign at home; the
15
15
  // ledger is the only witness we accept.
16
- import { startReceive } from "./receive";
17
- import { qrSvg } from "./qr";
16
+ import { startReceive } from "./receive.js";
17
+ import { qrSvg } from "./qr.js";
18
18
  export const DONATE_HOST = "donate.paydlux.com";
19
19
  export const DONATE_PORT = 4024;
20
20
  /**
@@ -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
+ }>;