@paydlux/agent 0.1.2 → 0.1.4

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
@@ -33,7 +33,8 @@ stellar/agent.ts the buyer side — one class, one method call
33
33
  stellar/desk.ts approval queue: a human decides, in the clear
34
34
  stellar/portal.ts the page that human looks at
35
35
  stellar/policy.ts daily budget / seller allowlist (the part a ledger can't count)
36
- stellar/demo.ts 12 checks against the live testnet, one command
36
+ stellar/demo.ts 17 checks against the live testnet, one command
37
+ phone-app/ the approver app a human opens on their own device
37
38
  stellar/probe-*.ts the experiments that settled how the ledger behaves
38
39
  stellar/onboard.ts keys → funded vault → printed agent key
39
40
  ```
@@ -42,12 +43,41 @@ stellar/onboard.ts keys → funded vault → printed agent key
42
43
 
43
44
  ```bash
44
45
  npm i
45
- npx tsx stellar/demo.ts # 12 checks, live testnet
46
- npx tsx stellar/story.ts --public # one unguessable link; approve from your phone
46
+ npx tsx stellar/demo.ts # 17 checks, live testnet
47
+ npx tsx stellar/story.ts --public --split # unguessable link; approve on your phone
47
48
  ```
48
49
 
49
50
  No wallet, no faucet, no config.
50
51
 
52
+ ### The human key does not live on the seller's machine
53
+
54
+ The vault needs two signatures. If both keys sit in one process, that is really
55
+ one key. So the approval half is split off:
56
+
57
+ ```bash
58
+ npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
59
+ npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
60
+ npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
61
+ npx tsx phone-app/desk-app-check.ts # 28 checks: the real app over real HTTP, end to end
62
+ ```
63
+
64
+ The seller builds the payment and signs it with their own half. The human opens
65
+ one link — `/phone/?id=...` — and the approver app in `phone-app/` shows them the
66
+ amount, the token, the payee and the deadline, then signs on the device.
67
+
68
+ The app is three static files with no dependencies and no build step. It carries
69
+ its own Stellar transaction decoder (`phone-app/xdr.js`) rather than the SDK, and
70
+ refuses any transaction shape it does not fully understand: path payments,
71
+ account merges, signer changes and multi-operation transactions are rejected
72
+ outright rather than guessed at. The key is imported into WebCrypto as
73
+ **non-extractable** and only the `CryptoKey` object itself is persisted, so the
74
+ seed is never in localStorage, IndexedDB, or a file. The page is served with
75
+ `connect-src 'self'`, so an injected script cannot phone the key home.
76
+
77
+ The approver seed never travels to the seller's machine, and the signature is
78
+ over the transaction hash — change the amount or the payee and it stops counting.
79
+ `tools/phone-sign.ts` is the same flow for a terminal instead of a browser.
80
+
51
81
  Use it as a package instead of cloning:
52
82
 
53
83
  ```bash
package/dist/desk.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import type { StellarVault } from "./vault.ts";
3
+ import { type ApprovalChallenge } from "./phone.ts";
3
4
  /** A portal link is a capability: whoever holds it can approve. So it is long,
4
5
  * random, and compared without leaking timing. */
5
6
  export declare function newPortalKey(): string;
@@ -9,10 +10,14 @@ export type DeskRecord = {
9
10
  uri: string;
10
11
  price: string;
11
12
  body: string;
12
- status: "pending" | "approved" | "denied" | "settled" | "failed";
13
+ status: "pending" | "awaiting_phone" | "approved" | "denied" | "settled" | "failed";
13
14
  content?: string;
14
15
  tx?: string;
15
16
  reason?: string;
17
+ /** split mode: what the phone is asked to sign, and what it sent back */
18
+ challenge?: ApprovalChallenge;
19
+ qr?: string;
20
+ signedXdr?: string;
16
21
  };
17
22
  export type DeskStore = DeskRecord;
18
23
  export type DeskConfig = {
@@ -21,6 +26,14 @@ export type DeskConfig = {
21
26
  /** seeds of the two human keys. DEV ONLY — in production these live on the phone. */
22
27
  principal?: string;
23
28
  approver?: string;
29
+ /**
30
+ * Split signing: the desk holds only the agent/principal half. Approving
31
+ * produces a challenge the phone signs offline; the signature comes back and
32
+ * the desk submits it. The approver seed never touches this machine.
33
+ */
34
+ split?: boolean;
35
+ /** how long a human has to decide before the payment is dead */
36
+ decideWithinSeconds?: number;
24
37
  port: number;
25
38
  /** If set, the portal and its actions require this key in the URL.
26
39
  * Required before putting the desk on any public URL. */
@@ -29,15 +42,35 @@ export type DeskConfig = {
29
42
  export declare class ApprovalDesk {
30
43
  private records;
31
44
  private seq;
45
+ private server?;
32
46
  cfg: DeskConfig;
33
47
  constructor(cfg: DeskConfig);
34
48
  enqueue(uri: string, price: string, body: string, content: string): string;
35
49
  get(id: string): DeskRecord | null;
36
50
  list(): DeskRecord[];
37
- /** The human clicked Approve: sign and submit the release. */
51
+ /**
52
+ * The human clicked Approve.
53
+ *
54
+ * dev mode (approver seed present): sign and submit here. One machine holds
55
+ * both keys, which is fine for a testnet demo and nothing else.
56
+ * split mode (no approver seed): build the challenge, sign it with the
57
+ * principal only, and hand it to the phone. The money moves when
58
+ * /signed comes back with the phone's signature.
59
+ */
38
60
  approve(id: string): Promise<DeskRecord>;
61
+ /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
62
+ private requestPhoneSignature;
63
+ /**
64
+ * Split mode step 2: the phone answered. `signedXdr` is the transaction with
65
+ * the approver signature added, returned by signChallenge() on the phone.
66
+ * The desk cannot forge this: it does not hold the approver key, and the
67
+ * ledger rejects it if the signature is not over this exact transaction.
68
+ */
69
+ complete(id: string, signedXdr: string): Promise<DeskRecord>;
39
70
  deny(id: string, reason?: string): DeskRecord;
40
71
  /** Serve the pending list as JSON (the portal polls this). */
41
72
  api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
42
73
  listen(port?: number): Promise<void>;
74
+ /** Stop serving. Without this the process never exits. */
75
+ close(): Promise<void>;
43
76
  }
package/dist/desk.js CHANGED
@@ -10,12 +10,93 @@
10
10
  // - a stolen phone cannot approve alone
11
11
  // - the approval is a real ledger signature, not a database flag
12
12
  import { createServer } from "node:http";
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
13
16
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
17
  import { Keypair } from "@stellar/stellar-sdk";
18
+ import { TransactionBuilder } from "@stellar/stellar-sdk";
19
+ import { passphrase } from "./vault.js";
15
20
  import { portalPage } from "./portal.js";
21
+ import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
22
+ /**
23
+ * The approver app is plain static files under phone-app/. Read on each request
24
+ * rather than bundled, so the desk and the app can never drift apart.
25
+ */
26
+ const APP_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "phone-app");
27
+ const appCache = new Map();
28
+ function readAppFile(name) {
29
+ const safe = name.replace(/[^a-z.]/g, "");
30
+ const hit = appCache.get(safe);
31
+ if (hit)
32
+ return hit;
33
+ let text;
34
+ try {
35
+ text = readFileSync(join(APP_DIR, safe), "utf8");
36
+ }
37
+ catch {
38
+ // A package install that shipped without phone-app/ should say so instead of
39
+ // throwing out of the request handler.
40
+ throw new Error("approver app is missing from this install (expected " + join(APP_DIR, safe) + ")");
41
+ }
42
+ appCache.set(safe, text);
43
+ return text;
44
+ }
16
45
  /** A portal link is a capability: whoever holds it can approve. So it is long,
17
46
  * random, and compared without leaking timing. */
18
47
  export function newPortalKey() { return randomBytes(16).toString("base64url"); }
48
+ /**
49
+ * Check that a signature the phone sent back belongs to THIS payment.
50
+ * Returns a reason string if it does not, or null if it is fine.
51
+ */
52
+ function verifyAgainstChallenge(signedXdr, rec) {
53
+ const ch = rec.challenge;
54
+ let signed;
55
+ try {
56
+ signed = TransactionBuilder.fromXDR(signedXdr, passphrase(recNetwork(rec)));
57
+ }
58
+ catch {
59
+ return "the phone returned something that is not a valid transaction for this network";
60
+ }
61
+ // 1. same transaction the human was shown: same hash, so the amount, payee,
62
+ // source account and sequence are all identical to what was approved.
63
+ const hash = Buffer.from(signed.hash()).toString("hex");
64
+ if (hash !== ch.hash) {
65
+ return `signed a different transaction (${hash.slice(0, 12)}… was not issued for ${rec.id}, which is ${ch.hash.slice(0, 12)}…)`;
66
+ }
67
+ // 2. the phone added exactly one signature; nothing else changed.
68
+ if (signed.signatures.length < 2) {
69
+ return `only ${signed.signatures.length} signature(s); the vault needs 2`;
70
+ }
71
+ // 3. belt and braces: the payment still says what the record says.
72
+ const op = signed.operations?.[0];
73
+ if (!op || op.type !== "payment")
74
+ return "the transaction is not a payment";
75
+ if (op.destination !== rec.challenge.summary.payee)
76
+ return "payee does not match the approval";
77
+ const want = rec.challenge.summary.amount;
78
+ if (op.amount !== want && Number(op.amount) !== Number(want)) {
79
+ return `amount ${op.amount} does not match the approved ${want}`;
80
+ }
81
+ return null;
82
+ }
83
+ function recNetwork(rec) {
84
+ return rec.challenge.network;
85
+ }
86
+ function readBody(req) {
87
+ return new Promise((resolve, reject) => {
88
+ let data = "";
89
+ req.on("data", (c) => {
90
+ data += c;
91
+ if (data.length > 64_000) {
92
+ reject(new Error("body too large"));
93
+ req.destroy();
94
+ }
95
+ });
96
+ req.on("end", () => resolve(data));
97
+ req.on("error", reject);
98
+ });
99
+ }
19
100
  function same(a, b) {
20
101
  const x = Buffer.from(a), y = Buffer.from(b);
21
102
  return x.length === y.length && timingSafeEqual(x, y);
@@ -23,6 +104,7 @@ function same(a, b) {
23
104
  export class ApprovalDesk {
24
105
  records = new Map();
25
106
  seq = 0;
107
+ server;
26
108
  cfg;
27
109
  constructor(cfg) { this.cfg = cfg; }
28
110
  enqueue(uri, price, body, content) {
@@ -33,13 +115,23 @@ export class ApprovalDesk {
33
115
  }
34
116
  get(id) { return this.records.get(id) ?? null; }
35
117
  list() { return [...this.records.values()]; }
36
- /** The human clicked Approve: sign and submit the release. */
118
+ /**
119
+ * The human clicked Approve.
120
+ *
121
+ * dev mode (approver seed present): sign and submit here. One machine holds
122
+ * both keys, which is fine for a testnet demo and nothing else.
123
+ * split mode (no approver seed): build the challenge, sign it with the
124
+ * principal only, and hand it to the phone. The money moves when
125
+ * /signed comes back with the phone's signature.
126
+ */
37
127
  async approve(id) {
38
128
  const rec = this.records.get(id);
39
129
  if (!rec)
40
130
  throw new Error("unknown handle " + id);
41
131
  if (rec.status !== "pending")
42
132
  throw new Error(`already ${rec.status}`);
133
+ if (this.cfg.split)
134
+ return this.requestPhoneSignature(rec);
43
135
  if (!this.cfg.principal || !this.cfg.approver) {
44
136
  rec.status = "failed";
45
137
  rec.reason = "no human keys configured (portal signing not wired yet)";
@@ -56,6 +148,75 @@ export class ApprovalDesk {
56
148
  }
57
149
  return rec;
58
150
  }
151
+ /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
152
+ async requestPhoneSignature(rec) {
153
+ if (!this.cfg.principal) {
154
+ rec.status = "failed";
155
+ rec.reason = "split mode needs the principal seed on the seller's side";
156
+ return rec;
157
+ }
158
+ try {
159
+ const challenge = await createApprovalChallenge({
160
+ vaultAddress: this.cfg.vault.address,
161
+ principal: Keypair.fromSecret(this.cfg.principal),
162
+ payee: this.cfg.payee,
163
+ amount: rec.price,
164
+ tokenCode: this.cfg.vault.token.getCode(),
165
+ tokenIssuer: this.cfg.vault.token.getIssuer() ?? "",
166
+ network: this.cfg.vault.network,
167
+ horizon: this.cfg.vault.horizon,
168
+ id: rec.id,
169
+ memo: rec.id,
170
+ decideWithinSeconds: this.cfg.decideWithinSeconds,
171
+ });
172
+ rec.challenge = challenge;
173
+ rec.qr = challengeQrPayload(challenge);
174
+ rec.status = "awaiting_phone";
175
+ console.log(`[desk] ${rec.id} sent to the phone: ${rec.price} for ${rec.uri}`);
176
+ }
177
+ catch (e) {
178
+ rec.status = "failed";
179
+ rec.reason = e.message;
180
+ }
181
+ return rec;
182
+ }
183
+ /**
184
+ * Split mode step 2: the phone answered. `signedXdr` is the transaction with
185
+ * the approver signature added, returned by signChallenge() on the phone.
186
+ * The desk cannot forge this: it does not hold the approver key, and the
187
+ * ledger rejects it if the signature is not over this exact transaction.
188
+ */
189
+ async complete(id, signedXdr) {
190
+ const rec = this.records.get(id);
191
+ if (!rec)
192
+ throw new Error("unknown handle " + id);
193
+ if (rec.status !== "awaiting_phone")
194
+ throw new Error(`not awaiting a phone signature (it is ${rec.status})`);
195
+ if (!rec.challenge)
196
+ throw new Error("no challenge was issued for " + id);
197
+ // Bind the answer to the question. Without this check the desk would submit
198
+ // any signed transaction posted to this route — including one the phone
199
+ // signed for a DIFFERENT payment, at a different price. That is how a
200
+ // payments bug becomes a wrong-amount bug.
201
+ const problem = verifyAgainstChallenge(signedXdr, rec);
202
+ if (problem) {
203
+ rec.status = "failed";
204
+ rec.reason = problem;
205
+ console.log(`[desk] ${id} rejected: ${problem}`);
206
+ return rec;
207
+ }
208
+ rec.signedXdr = signedXdr;
209
+ try {
210
+ const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
211
+ rec.status = "settled";
212
+ rec.tx = res.hash;
213
+ }
214
+ catch (e) {
215
+ rec.status = "failed";
216
+ rec.reason = e.message;
217
+ }
218
+ return rec;
219
+ }
59
220
  deny(id, reason = "denied by the approver") {
60
221
  const rec = this.records.get(id);
61
222
  if (!rec)
@@ -70,6 +231,7 @@ export class ApprovalDesk {
70
231
  api(handler) { return handler; }
71
232
  listen(port = this.cfg.port) {
72
233
  const srv = createServer(async (req, res) => {
234
+ // (server handle kept below so close() can stop it)
73
235
  const url = new URL(req.url ?? "/", "http://localhost");
74
236
  // A public demo link must not be an open approval button. If a portalKey
75
237
  // is configured, every route needs it, or the desk answers 403.
@@ -91,8 +253,75 @@ export class ApprovalDesk {
91
253
  }
92
254
  if (url.pathname === "/list")
93
255
  return send(200, this.list());
94
- const m = url.pathname.match(/^\/(approve|deny)\/([\w-]+)$/);
256
+ // The approver app. Served from the desk so the human opens one link on
257
+ // their phone instead of pasting XDR around. The app itself makes no
258
+ // requests after loading, and the portalKey gate above already covers
259
+ // these paths: without the key, nothing here is reachable.
260
+ // The app uses relative script srcs, so it must be served from a
261
+ // directory URL. /phone without the slash would resolve app.js to
262
+ // /app.js, so send it to the canonical path with the key intact.
263
+ if (url.pathname === "/phone") {
264
+ res.writeHead(302, { location: "/phone/" + url.search });
265
+ res.end();
266
+ return;
267
+ }
268
+ if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
269
+ res.writeHead(200, {
270
+ "content-type": "text/html; charset=utf-8",
271
+ // The key lives in IndexedDB behind a non-extractable CryptoKey;
272
+ // an injected script is the realistic threat, so lock the origin.
273
+ // No inline script on the page: app.js and xdr.js are separate
274
+ // files, so script-src 'self' actually holds. connect-src 'self' is
275
+ // what stops the app talking anywhere but the desk that served it.
276
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
277
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
278
+ "form-action 'none'; frame-ancestors 'none'",
279
+ "referrer-policy": "no-referrer",
280
+ "x-content-type-options": "nosniff",
281
+ });
282
+ res.end(readAppFile("index.html"));
283
+ return;
284
+ }
285
+ if (url.pathname.startsWith("/phone/")) {
286
+ const file = url.pathname.slice("/phone/".length);
287
+ if (!/^[a-z]+\.(js|css)$/.test(file))
288
+ return send(404, { error: "not found" });
289
+ res.writeHead(200, {
290
+ "content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
291
+ "cache-control": "no-store",
292
+ "x-content-type-options": "nosniff",
293
+ });
294
+ res.end(readAppFile(file));
295
+ return;
296
+ }
297
+ // The app fetches the pending transaction by handle rather than asking
298
+ // the human to paste 400 characters of base64 on a phone.
299
+ const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
300
+ if (ch) {
301
+ const rec = this.records.get(ch[1]);
302
+ if (!rec)
303
+ return send(404, { error: "unknown handle" });
304
+ // Opening the phone link IS the approval gesture, so build the
305
+ // challenge here if nobody pressed the button yet. The human should
306
+ // need one link, not a button on a desktop plus a link on a phone.
307
+ if (rec.status === "pending")
308
+ await this.approve(rec.id);
309
+ if (!rec.challenge)
310
+ return send(409, { error: `not awaiting a phone signature (it is ${rec.status})` });
311
+ return send(200, {
312
+ id: rec.id, network: rec.challenge.network,
313
+ hash: rec.challenge.hash, xdr: rec.challenge.xdr,
314
+ });
315
+ }
316
+ const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
95
317
  if (m && req.method === "POST") {
318
+ if (m[1] === "signed") {
319
+ const body = await readBody(req);
320
+ const { signedXdr } = JSON.parse(body || "{}");
321
+ if (!signedXdr)
322
+ return send(400, { error: "signedXdr is required" });
323
+ return send(200, await this.complete(m[2], String(signedXdr)));
324
+ }
96
325
  const rec = m[1] === "approve" ? await this.approve(m[2]) : this.deny(m[2]);
97
326
  return send(200, rec);
98
327
  }
@@ -102,6 +331,15 @@ export class ApprovalDesk {
102
331
  return send(400, { error: e.message });
103
332
  }
104
333
  });
334
+ this.server = srv;
105
335
  return new Promise((r) => srv.listen(port, () => r()));
106
336
  }
337
+ /** Stop serving. Without this the process never exits. */
338
+ close() {
339
+ return new Promise((r) => {
340
+ if (!this.server)
341
+ return r();
342
+ this.server.close(() => r());
343
+ });
344
+ }
107
345
  }
package/dist/gateway.d.ts CHANGED
@@ -19,6 +19,14 @@ export type GatewayConfig = {
19
19
  /** human keys. In production these live on the phone, never here. */
20
20
  principal?: string;
21
21
  approver?: string;
22
+ /**
23
+ * Split mode: the desk gets the principal key (the seller's own half) but NOT
24
+ * the approver key, so this process cannot approve anything. Approval happens
25
+ * on a second device through the approver app. Pass `approver: undefined`
26
+ * together with this; the gateway still settles, using the signature the
27
+ * phone sends back.
28
+ */
29
+ split?: boolean;
22
30
  deskPort?: number;
23
31
  /** gate the approval portal behind a single unguessable link */
24
32
  portalKey?: string;
package/dist/gateway.js CHANGED
@@ -35,6 +35,7 @@ export class Gateway {
35
35
  approver: cfg.approver,
36
36
  port: cfg.deskPort ?? 4022,
37
37
  portalKey: cfg.portalKey,
38
+ split: cfg.split,
38
39
  });
39
40
  }
40
41
  /** Mandates the vault currently holds, so the gateway can hand them out. */
package/dist/index.d.ts CHANGED
@@ -9,3 +9,6 @@ export type { DeskConfig, DeskRecord } from "./desk.ts";
9
9
  export { portalPage } from "./portal.ts";
10
10
  export { PolicyEngine, DEFAULT_POLICY } from "./policy.ts";
11
11
  export type { Policy, SpendEvent, Verdict } from "./policy.ts";
12
+ export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.ts";
13
+ export { txResultName } from "./vault.ts";
14
+ export type { ApprovalChallenge } from "./phone.ts";
package/dist/index.js CHANGED
@@ -7,3 +7,6 @@ export { PaydluxAgent } from "./agent.js";
7
7
  export { ApprovalDesk, newPortalKey } from "./desk.js";
8
8
  export { portalPage } from "./portal.js";
9
9
  export { PolicyEngine, DEFAULT_POLICY } from "./policy.js";
10
+ // Split signing: the human's key lives on a phone, not in the seller's process.
11
+ export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.js";
12
+ export { txResultName } from "./vault.js";
@@ -0,0 +1,65 @@
1
+ import { Keypair, StrKey } from "@stellar/stellar-sdk";
2
+ import type { Network } from "./vault.ts";
3
+ /** What the seller sends to the phone. Unsigned on purpose. */
4
+ export type ApprovalChallenge = {
5
+ id: string;
6
+ network: Network;
7
+ /** the transaction, as XDR, signed by the principal only */
8
+ xdr: string;
9
+ /** what a human should read before signing */
10
+ summary: {
11
+ payee: string;
12
+ amount: string;
13
+ token: string;
14
+ tokenIssuer: string;
15
+ from: string;
16
+ memo?: string;
17
+ expiresAt?: number;
18
+ };
19
+ hash: string;
20
+ };
21
+ /**
22
+ * Build the payment and sign it with the principal key only. The result is
23
+ * deliberately incomplete: it reaches the vault's threshold of 2 only once the
24
+ * approver's signature is added.
25
+ */
26
+ export declare function createApprovalChallenge(opts: {
27
+ vaultAddress: string;
28
+ principal: Keypair;
29
+ payee: string;
30
+ amount: string;
31
+ tokenCode: string;
32
+ tokenIssuer: string;
33
+ network: Network;
34
+ horizon: import("@stellar/stellar-sdk").Horizon.Server;
35
+ id: string;
36
+ memo?: string;
37
+ /** how long the human has to decide; the tx is dead after this */
38
+ decideWithinSeconds?: number;
39
+ }): Promise<ApprovalChallenge>;
40
+ /**
41
+ * What a person (or a phone app) does with a challenge: read it, decide, sign.
42
+ *
43
+ * This function does not talk to the network. It cannot submit anything. It
44
+ * cannot change the amount. It either adds a valid signature or returns the
45
+ * challenge untouched. That is the whole point of running it on the phone.
46
+ */
47
+ export declare function signChallenge(challenge: ApprovalChallenge, approverSeed: string): string;
48
+ /**
49
+ * Read a challenge without any secrets, to show a human. Deliberately separate
50
+ * from signing so a UI can render it on an untrusted machine.
51
+ */
52
+ export declare function describeChallenge(challenge: ApprovalChallenge): string;
53
+ /**
54
+ * The seller's side: take the phone's signature back and submit. Fails loudly
55
+ * if the transaction is still short of the vault's threshold.
56
+ */
57
+ export declare function submitApproved(signedXdr: string, network: Network, horizon: import("@stellar/stellar-sdk").Horizon.Server): Promise<{
58
+ hash: string;
59
+ ok: true;
60
+ }>;
61
+ /** A stable, scannable form of a challenge — what a phone would read from a QR code. */
62
+ export declare function challengeQrPayload(challenge: ApprovalChallenge): string;
63
+ /** Parse what a QR code carried. Rejects a payload whose hash disagrees. */
64
+ export declare function parseChallengeQr(payload: string): ApprovalChallenge;
65
+ export { StrKey };