@paydlux/agent 0.1.9 → 0.1.10
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 +8 -2
- package/dist/cli.js +48 -0
- package/dist/desk.d.ts +16 -0
- package/dist/desk.js +123 -49
- package/package.json +5 -1
- package/phone-app/app.js +405 -226
- package/phone-app/icon-192.png +0 -0
- package/phone-app/icon-512.png +0 -0
- package/phone-app/index.html +81 -18
- package/phone-app/manifest.webmanifest +15 -0
- package/phone-app/xdr.js +45 -5
package/README.md
CHANGED
|
@@ -84,11 +84,17 @@ one key. So the approval half is split off:
|
|
|
84
84
|
npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
|
|
85
85
|
npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
|
|
86
86
|
npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
|
|
87
|
-
npx tsx phone-app/desk-app-check.ts #
|
|
87
|
+
npx tsx phone-app/desk-app-check.ts # 57 checks: the real app over real HTTP, end to end
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
The seller builds the payment and signs it with their own half. The human opens
|
|
91
|
-
one link — `/phone/?
|
|
91
|
+
one link — `/phone/?k=...` — and the approver app in `phone-app/` shows them the
|
|
92
|
+
`https://desk.paydlux.com/phone/` link opens an installable app: it has a manifest
|
|
93
|
+
and icons, so a phone can put it on the home screen and it runs full-screen. The
|
|
94
|
+
shell (html, js, icons) is public because it holds no secret and no payment data
|
|
95
|
+
and must load before the key does; every route that returns data or moves money
|
|
96
|
+
(`/queue`, `/challenge`, `/approve`, `/signed`, `/deny`) still answers 403 without
|
|
97
|
+
the portal key.
|
|
92
98
|
amount, the token, the payee and the deadline, then signs on the device.
|
|
93
99
|
|
|
94
100
|
The app is three static files with no dependencies and no build step. It carries
|
package/dist/cli.js
CHANGED
|
@@ -407,6 +407,49 @@ removed from ${path}. this machine can no longer approve anything by itself.
|
|
|
407
407
|
sell with: paydlux sell --split --tls
|
|
408
408
|
`);
|
|
409
409
|
}
|
|
410
|
+
// --------------------------------------------------------------------- withdraw
|
|
411
|
+
/**
|
|
412
|
+
* Take your money back out of the vault. This is a normal 2-of-2 payment:
|
|
413
|
+
* principal (this laptop) + approver (your phone, or the laptop until you run
|
|
414
|
+
* `approver --forget`). The agent key cannot do this — it has no path to an
|
|
415
|
+
* arbitrary destination, which is the whole point of the vault.
|
|
416
|
+
*
|
|
417
|
+
* The XLM the vault pays in ledger rent stays locked while the account exists.
|
|
418
|
+
* Closing the vault (not done here) returns that too.
|
|
419
|
+
*/
|
|
420
|
+
async function withdraw() {
|
|
421
|
+
const { data } = loadVault();
|
|
422
|
+
const to = arg("to");
|
|
423
|
+
const amount = arg("amount");
|
|
424
|
+
const all = flag("all");
|
|
425
|
+
if (!to)
|
|
426
|
+
bad(`where should it go?\n\n paydlux withdraw --to G… [--all | --amount 8]\n\n --to can be any Stellar account, including your Binance deposit address.`);
|
|
427
|
+
const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, {
|
|
428
|
+
principal: pub(data.keys.principal),
|
|
429
|
+
approver: data.keys.approver ? pub(data.keys.approver) : undefined,
|
|
430
|
+
agent: pub(data.keys.agent),
|
|
431
|
+
}, (data.network ?? "testnet"));
|
|
432
|
+
if (data.token.code === "USDC")
|
|
433
|
+
vault.useUsdc();
|
|
434
|
+
const bal = await vault.balance();
|
|
435
|
+
const want = all ? bal : amount;
|
|
436
|
+
if (!want)
|
|
437
|
+
bad(`say how much: --all, or --amount 8 (vault holds ${bal} ${data.token.code})`);
|
|
438
|
+
if (Number(want) > Number(bal))
|
|
439
|
+
bad(`the vault holds ${bal} ${data.token.code}, not ${want}.`);
|
|
440
|
+
const keys = data.keys ?? {};
|
|
441
|
+
const principal = Keypair.fromSecret(keys.principal);
|
|
442
|
+
const approver = keys.approver ? Keypair.fromSecret(keys.approver) : null;
|
|
443
|
+
if (!approver) {
|
|
444
|
+
console.log(`this laptop holds only one of the two keys, so the withdrawal needs your phone.
|
|
445
|
+
open: ${flag("tls") ? `https://${PUBLIC_HOST}` : "http://127.0.0.1:4022"}/?k=${data.desk?.portalKey ?? "…"}`);
|
|
446
|
+
bad("then run this again — approvals from the phone land automatically.");
|
|
447
|
+
}
|
|
448
|
+
console.log(`sending ${want} ${data.token.code} from the vault to ${to} …`);
|
|
449
|
+
const res = await vault.release(to, String(want), principal, approver, "paydlux withdraw");
|
|
450
|
+
console.log(`done. tx ${res?.hash}`);
|
|
451
|
+
console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
|
|
452
|
+
}
|
|
410
453
|
// ------------------------------------------------------------------------- main
|
|
411
454
|
const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
412
455
|
|
|
@@ -419,6 +462,9 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
419
462
|
paydlux approver [--forget]
|
|
420
463
|
print the human half of the vault, then optionally delete it from this
|
|
421
464
|
machine so only the phone can approve.
|
|
465
|
+
paydlux withdraw --to G… [--all | --amount N]
|
|
466
|
+
take your money back out of the vault. Needs both human keys; the agent
|
|
467
|
+
key can never do this.
|
|
422
468
|
|
|
423
469
|
one file holds it all: ./paydlux-vault.json (0600, never commit it)
|
|
424
470
|
`;
|
|
@@ -436,6 +482,8 @@ export async function main() {
|
|
|
436
482
|
return status();
|
|
437
483
|
if (cmd === "approver")
|
|
438
484
|
return approver();
|
|
485
|
+
if (cmd === "withdraw")
|
|
486
|
+
return withdraw();
|
|
439
487
|
bad(`unknown command "${cmd}".\n\n${HELP}`);
|
|
440
488
|
}
|
|
441
489
|
// Run only when this file is the entry point, so importing it in a test is safe.
|
package/dist/desk.d.ts
CHANGED
|
@@ -85,7 +85,23 @@ export declare class ApprovalDesk {
|
|
|
85
85
|
* ledger rejects it if the signature is not over this exact transaction.
|
|
86
86
|
*/
|
|
87
87
|
complete(id: string, signedXdr: string): Promise<DeskRecord>;
|
|
88
|
+
/**
|
|
89
|
+
* The human said no.
|
|
90
|
+
*
|
|
91
|
+
* `awaiting_phone` is a valid state to deny from. Opening the phone link
|
|
92
|
+
* builds the challenge on the spot (so a human needs one tap, not two), which
|
|
93
|
+
* means the first thing a person sees on their phone is already
|
|
94
|
+
* `awaiting_phone` — refusing to deny from that state left the Reject button
|
|
95
|
+
* dead, which was verified on live testnet. Denying is always allowed up to
|
|
96
|
+
* the point the transaction is submitted; after that it is too late.
|
|
97
|
+
*/
|
|
88
98
|
deny(id: string, reason?: string): DeskRecord;
|
|
99
|
+
/**
|
|
100
|
+
* What the phone app is allowed to see. Never the paid `content` — that is
|
|
101
|
+
* what the buyer has not paid for yet — and never the raw challenge XDR,
|
|
102
|
+
* which the app fetches per item so the desk can re-check the binding.
|
|
103
|
+
*/
|
|
104
|
+
queue(): Record<string, unknown>[];
|
|
89
105
|
/** Serve the pending list as JSON (the portal polls this). */
|
|
90
106
|
api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
|
|
91
107
|
listen(port?: number): Promise<void>;
|
package/dist/desk.js
CHANGED
|
@@ -31,25 +31,34 @@ const staticCache = new Map();
|
|
|
31
31
|
* Static files are read on each process start rather than bundled, so the desk
|
|
32
32
|
* and its front ends can never drift apart. The name is stripped to [a-z.]
|
|
33
33
|
* before it touches a path, so a request can't walk out of the directory.
|
|
34
|
+
*
|
|
35
|
+
* PNGs are returned as bytes: reading an image as utf8 text replaces every
|
|
36
|
+
* invalid sequence with U+FFFD and ships a corrupt icon to the phone.
|
|
34
37
|
*/
|
|
35
38
|
function readStatic(dir, name, label) {
|
|
36
|
-
const safe = name.replace(/[^a-
|
|
39
|
+
const safe = name.replace(/[^a-z0-9._-]/g, "");
|
|
37
40
|
const hit = staticCache.get(dir + "/" + safe);
|
|
38
41
|
if (hit)
|
|
39
42
|
return hit;
|
|
40
43
|
let text;
|
|
41
44
|
try {
|
|
42
|
-
text =
|
|
45
|
+
text = safe.endsWith(".png")
|
|
46
|
+
? readFileSync(join(dir, safe))
|
|
47
|
+
: readFileSync(join(dir, safe), "utf8");
|
|
43
48
|
}
|
|
44
49
|
catch {
|
|
45
|
-
// A
|
|
46
|
-
//
|
|
47
|
-
|
|
50
|
+
// A missing file is a 404, not a crash. Throwing here took the whole desk
|
|
51
|
+
// down over one bad URL: the request handler is not a place to raise.
|
|
52
|
+
return null;
|
|
48
53
|
}
|
|
49
54
|
staticCache.set(dir + "/" + safe, text);
|
|
50
55
|
return text;
|
|
51
56
|
}
|
|
52
57
|
const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
|
|
58
|
+
const notFound = (res) => {
|
|
59
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
60
|
+
res.end("404");
|
|
61
|
+
};
|
|
53
62
|
const readPortalFile = (name) => readStatic(PORTAL_DIR, name, "portal app");
|
|
54
63
|
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
55
64
|
* random, and compared without leaking timing. */
|
|
@@ -258,16 +267,51 @@ export class ApprovalDesk {
|
|
|
258
267
|
}
|
|
259
268
|
return rec;
|
|
260
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* The human said no.
|
|
272
|
+
*
|
|
273
|
+
* `awaiting_phone` is a valid state to deny from. Opening the phone link
|
|
274
|
+
* builds the challenge on the spot (so a human needs one tap, not two), which
|
|
275
|
+
* means the first thing a person sees on their phone is already
|
|
276
|
+
* `awaiting_phone` — refusing to deny from that state left the Reject button
|
|
277
|
+
* dead, which was verified on live testnet. Denying is always allowed up to
|
|
278
|
+
* the point the transaction is submitted; after that it is too late.
|
|
279
|
+
*/
|
|
261
280
|
deny(id, reason = "denied by the approver") {
|
|
262
281
|
const rec = this.records.get(id);
|
|
263
282
|
if (!rec)
|
|
264
283
|
throw new Error("unknown handle " + id);
|
|
265
|
-
if (rec.status
|
|
266
|
-
throw new Error(
|
|
284
|
+
if (rec.status === "settled")
|
|
285
|
+
throw new Error("already settled — the money has moved, it cannot be denied");
|
|
286
|
+
if (rec.status === "denied")
|
|
287
|
+
return rec; // idempotent: the app may retry
|
|
267
288
|
rec.status = "denied";
|
|
268
289
|
rec.reason = reason;
|
|
290
|
+
rec.challenge = undefined; // the phone's answer is now worthless
|
|
291
|
+
rec.signedXdr = undefined;
|
|
269
292
|
return rec;
|
|
270
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* What the phone app is allowed to see. Never the paid `content` — that is
|
|
296
|
+
* what the buyer has not paid for yet — and never the raw challenge XDR,
|
|
297
|
+
* which the app fetches per item so the desk can re-check the binding.
|
|
298
|
+
*/
|
|
299
|
+
queue() {
|
|
300
|
+
return this.list().map((r) => ({
|
|
301
|
+
id: r.id,
|
|
302
|
+
at: r.at,
|
|
303
|
+
uri: r.uri,
|
|
304
|
+
price: r.price,
|
|
305
|
+
status: r.status,
|
|
306
|
+
reason: r.reason,
|
|
307
|
+
tx: r.tx,
|
|
308
|
+
/** what the buyer says it wants. Agent-controlled text: render as text. */
|
|
309
|
+
wants: (r.body ?? "").slice(0, 200),
|
|
310
|
+
token: this.cfg.vault.token.getCode(),
|
|
311
|
+
expiresAt: r.challenge?.summary.expiresAt,
|
|
312
|
+
hash: r.challenge?.hash,
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
271
315
|
/** Serve the pending list as JSON (the portal polls this). */
|
|
272
316
|
api(handler) { return handler; }
|
|
273
317
|
listen(port = this.cfg.port) {
|
|
@@ -329,6 +373,65 @@ export class ApprovalDesk {
|
|
|
329
373
|
res.end("ok");
|
|
330
374
|
return;
|
|
331
375
|
}
|
|
376
|
+
// The approver app's static shell is served BEFORE the key gate, on
|
|
377
|
+
// purpose. It is index.html, app.js, xdr.js, the icons and the manifest:
|
|
378
|
+
// client code with no secret and no payment data in it, the same as the
|
|
379
|
+
// portal's own portal.js. Serving it unauthenticated is what lets a phone
|
|
380
|
+
// install it to a home screen from a plain URL, with the seller's key
|
|
381
|
+
// arriving separately in the link. Everything that carries data or moves
|
|
382
|
+
// money — /queue, /challenge, /list, /approve, /deny, /signed — stays
|
|
383
|
+
// behind the gate below.
|
|
384
|
+
//
|
|
385
|
+
// The app uses relative script srcs, so it must be served from a
|
|
386
|
+
// directory URL: /phone without the slash would resolve app.js to /app.js.
|
|
387
|
+
if (url.pathname === "/phone") {
|
|
388
|
+
res.writeHead(302, { location: "/phone/" + url.search });
|
|
389
|
+
res.end();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
|
|
393
|
+
res.writeHead(200, {
|
|
394
|
+
"content-type": "text/html; charset=utf-8",
|
|
395
|
+
// The approver key goes in IndexedDB behind a non-extractable
|
|
396
|
+
// CryptoKey; an injected script is the realistic threat, so lock the
|
|
397
|
+
// origin. No inline script on the page: app.js and xdr.js are
|
|
398
|
+
// separate files, so script-src 'self' actually holds. connect-src
|
|
399
|
+
// 'self' is what stops the app talking anywhere but the desk that
|
|
400
|
+
// served it.
|
|
401
|
+
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
402
|
+
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
|
|
403
|
+
"form-action 'none'; frame-ancestors 'none'",
|
|
404
|
+
"referrer-policy": "no-referrer",
|
|
405
|
+
"x-content-type-options": "nosniff",
|
|
406
|
+
"cache-control": "no-store",
|
|
407
|
+
});
|
|
408
|
+
const page = readAppFile("index.html");
|
|
409
|
+
if (page === null)
|
|
410
|
+
return notFound(res);
|
|
411
|
+
res.end(page);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (url.pathname.startsWith("/phone/")) {
|
|
415
|
+
const file = url.pathname.slice("/phone/".length);
|
|
416
|
+
// Icons and the manifest are part of installing the app to a home
|
|
417
|
+
// screen, so they are served alongside the code. Names are still
|
|
418
|
+
// stripped to a safe set before they touch a path.
|
|
419
|
+
if (!/^[a-z0-9_-]+\.(js|png|webmanifest)$/.test(file))
|
|
420
|
+
return notFound(res);
|
|
421
|
+
const type = file.endsWith(".png") ? "image/png"
|
|
422
|
+
: file.endsWith(".webmanifest") ? "application/manifest+json"
|
|
423
|
+
: "text/javascript";
|
|
424
|
+
const body = readAppFile(file);
|
|
425
|
+
if (body === null)
|
|
426
|
+
return notFound(res);
|
|
427
|
+
res.writeHead(200, {
|
|
428
|
+
"content-type": type + (file.endsWith(".png") ? "" : "; charset=utf-8"),
|
|
429
|
+
"cache-control": "no-store",
|
|
430
|
+
"x-content-type-options": "nosniff",
|
|
431
|
+
});
|
|
432
|
+
res.end(body);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
332
435
|
// A public demo link must not be an open approval button. If a portalKey
|
|
333
436
|
// is configured, every route needs it, or the desk answers 403.
|
|
334
437
|
//
|
|
@@ -364,56 +467,22 @@ export class ApprovalDesk {
|
|
|
364
467
|
// approval page and an injected string can't become executable script.
|
|
365
468
|
if (url.pathname === "/portal.js" || url.pathname === "/portal.css") {
|
|
366
469
|
const file = url.pathname.slice(1);
|
|
470
|
+
const body = readPortalFile(file);
|
|
471
|
+
if (body === null)
|
|
472
|
+
return notFound(res);
|
|
367
473
|
res.writeHead(200, {
|
|
368
474
|
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
369
475
|
"x-content-type-options": "nosniff",
|
|
370
476
|
});
|
|
371
|
-
res.end(
|
|
477
|
+
res.end(body);
|
|
372
478
|
return;
|
|
373
479
|
}
|
|
374
480
|
if (url.pathname === "/list")
|
|
375
481
|
return send(200, this.list());
|
|
376
|
-
// The approver app
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// The app uses relative script srcs, so it must be served from a
|
|
381
|
-
// directory URL. /phone without the slash would resolve app.js to
|
|
382
|
-
// /app.js, so send it to the canonical path with the key intact.
|
|
383
|
-
if (url.pathname === "/phone") {
|
|
384
|
-
res.writeHead(302, { location: "/phone/" + url.search });
|
|
385
|
-
res.end();
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
|
|
389
|
-
res.writeHead(200, {
|
|
390
|
-
"content-type": "text/html; charset=utf-8",
|
|
391
|
-
// The key lives in IndexedDB behind a non-extractable CryptoKey;
|
|
392
|
-
// an injected script is the realistic threat, so lock the origin.
|
|
393
|
-
// No inline script on the page: app.js and xdr.js are separate
|
|
394
|
-
// files, so script-src 'self' actually holds. connect-src 'self' is
|
|
395
|
-
// what stops the app talking anywhere but the desk that served it.
|
|
396
|
-
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
397
|
-
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
|
|
398
|
-
"form-action 'none'; frame-ancestors 'none'",
|
|
399
|
-
"referrer-policy": "no-referrer",
|
|
400
|
-
"x-content-type-options": "nosniff",
|
|
401
|
-
});
|
|
402
|
-
res.end(readAppFile("index.html"));
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
if (url.pathname.startsWith("/phone/")) {
|
|
406
|
-
const file = url.pathname.slice("/phone/".length);
|
|
407
|
-
if (!/^[a-z]+\.(js|css)$/.test(file))
|
|
408
|
-
return send(404, { error: "not found" });
|
|
409
|
-
res.writeHead(200, {
|
|
410
|
-
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
411
|
-
"cache-control": "no-store",
|
|
412
|
-
"x-content-type-options": "nosniff",
|
|
413
|
-
});
|
|
414
|
-
res.end(readAppFile(file));
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
482
|
+
// The approver app's list view. Key-gated like everything else, and it
|
|
483
|
+
// carries no paid content and no challenge XDR.
|
|
484
|
+
if (url.pathname === "/queue")
|
|
485
|
+
return send(200, this.queue());
|
|
417
486
|
// The app fetches the pending transaction by handle rather than asking
|
|
418
487
|
// the human to paste 400 characters of base64 on a phone.
|
|
419
488
|
const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
|
|
@@ -431,6 +500,11 @@ export class ApprovalDesk {
|
|
|
431
500
|
return send(200, {
|
|
432
501
|
id: rec.id, network: rec.challenge.network,
|
|
433
502
|
hash: rec.challenge.hash, xdr: rec.challenge.xdr,
|
|
503
|
+
// what the human is being asked to read. Agent-controlled text: the
|
|
504
|
+
// app renders it as text, never as markup.
|
|
505
|
+
wants: (rec.body ?? "").slice(0, 200),
|
|
506
|
+
memo: rec.challenge.summary.memo ?? "",
|
|
507
|
+
expiresAt: rec.challenge.summary.expiresAt,
|
|
434
508
|
});
|
|
435
509
|
}
|
|
436
510
|
const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"phone-app/index.html",
|
|
17
17
|
"phone-app/app.js",
|
|
18
18
|
"phone-app/xdr.js",
|
|
19
|
+
"phone-app/app.css",
|
|
20
|
+
"phone-app/manifest.webmanifest",
|
|
21
|
+
"phone-app/icon-192.png",
|
|
22
|
+
"phone-app/icon-512.png",
|
|
19
23
|
"portal-app/portal.js",
|
|
20
24
|
"portal-app/portal.css",
|
|
21
25
|
"README.md",
|
package/phone-app/app.js
CHANGED
|
@@ -1,253 +1,432 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const req = fn(tx.objectStore(STORE));
|
|
20
|
-
req.onsuccess = () => res(req.result);
|
|
21
|
-
req.onerror = () => rej(req.error);
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
const saveKey = (k) => idb("readwrite", (s) => s.put(k, "approver"));
|
|
26
|
-
const loadKey = () => idb("readonly", (s) => s.get("approver"));
|
|
27
|
-
const dropKey = () => idb("readwrite", (s) => s.delete("approver"));
|
|
28
|
-
|
|
29
|
-
function base64urlToBytes(s) {
|
|
30
|
-
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
|
|
31
|
-
const out = new Uint8Array(bin.length);
|
|
32
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
33
|
-
return out;
|
|
34
|
-
}
|
|
1
|
+
// Paydlux approver — the client half.
|
|
2
|
+
//
|
|
3
|
+
// Two jobs, and only two: hold the approver key on this device, and sign the
|
|
4
|
+
// transaction hash for a payment a human has read.
|
|
5
|
+
//
|
|
6
|
+
// Rules this file is written to:
|
|
7
|
+
// * The seed is imported into a NON-extractable WebCrypto key and then
|
|
8
|
+
// dropped. What is persisted is the CryptoKey, not the seed. An injected
|
|
9
|
+
// script can call sign(); it cannot read the seed.
|
|
10
|
+
// * Signing is local. The bytes signed are the transaction hash the app
|
|
11
|
+
// computed itself from the XDR it fetched — never a hash the server handed
|
|
12
|
+
// over as an opaque "please sign this".
|
|
13
|
+
// * Every string that came from a buyer's agent (request path, request body,
|
|
14
|
+
// reason) is written with textContent. Never innerHTML. This page authorises
|
|
15
|
+
// payments; one unescaped field there is remote code execution on the
|
|
16
|
+
// approver's phone.
|
|
17
|
+
// * One origin: the desk that served this app. No other fetch is made.
|
|
18
|
+
"use strict";
|
|
35
19
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
20
|
+
const X = globalThis.PaydluxXdr;
|
|
21
|
+
const $ = (id) => document.getElementById(id);
|
|
22
|
+
const show = (id, on = true) => $(id).classList.toggle("hidden", !on);
|
|
23
|
+
const set = (id, v) => { $(id).textContent = v == null || v === "" ? "\u2014" : String(v); };
|
|
24
|
+
const err = (id, msg) => { $(id).textContent = msg || ""; };
|
|
41
25
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
$("enrolErr").textContent = e.message;
|
|
71
|
-
}
|
|
26
|
+
// ------------------------------------------------------------------ storage
|
|
27
|
+
// The approver key goes in IndexedDB, not localStorage. localStorage can only
|
|
28
|
+
// hold strings, which would force us to re-encode the key as something
|
|
29
|
+
// readable; IndexedDB stores the CryptoKey object itself, and a
|
|
30
|
+
// non-extractable key has no readable form to store.
|
|
31
|
+
const DB = "paydlux-approver";
|
|
32
|
+
const idb = () => new Promise((res, rej) => {
|
|
33
|
+
const open = indexedDB.open(DB, 1);
|
|
34
|
+
open.onupgradeneeded = () => open.result.createObjectStore("keys");
|
|
35
|
+
open.onsuccess = () => res(open.result);
|
|
36
|
+
open.onerror = () => rej(open.error);
|
|
37
|
+
});
|
|
38
|
+
async function idbPut(value) {
|
|
39
|
+
const db = await idb();
|
|
40
|
+
return new Promise((res, rej) => {
|
|
41
|
+
const t = db.transaction("keys", "readwrite");
|
|
42
|
+
t.objectStore("keys").put(value, "approver");
|
|
43
|
+
t.oncomplete = () => res();
|
|
44
|
+
t.onerror = () => rej(t.error);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function idbGet() {
|
|
48
|
+
const db = await idb();
|
|
49
|
+
return new Promise((res, rej) => {
|
|
50
|
+
const t = db.transaction("keys", "readonly");
|
|
51
|
+
const g = t.objectStore("keys").get("approver");
|
|
52
|
+
g.onsuccess = () => res(g.result ?? null);
|
|
53
|
+
g.onerror = () => rej(g.error);
|
|
72
54
|
});
|
|
55
|
+
}
|
|
56
|
+
async function idbDel() {
|
|
57
|
+
const db = await idb();
|
|
58
|
+
return new Promise((res, rej) => {
|
|
59
|
+
const t = db.transaction("keys", "readwrite");
|
|
60
|
+
t.objectStore("keys").delete("approver");
|
|
61
|
+
t.oncomplete = () => res();
|
|
62
|
+
t.onerror = () => rej(t.error);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
73
65
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
66
|
+
// The approval link carries the seller's portal key. It is the thing that makes
|
|
67
|
+
// this app useful, so it is kept on the device after the seller opens it once.
|
|
68
|
+
const LINK = "paydlux.link";
|
|
69
|
+
const linkParams = () => {
|
|
70
|
+
const raw = localStorage.getItem(LINK) || "";
|
|
71
|
+
const u = new URL(raw, location.href);
|
|
72
|
+
return u.searchParams;
|
|
73
|
+
};
|
|
74
|
+
const key = () => linkParams().get("k") || "";
|
|
75
|
+
|
|
76
|
+
// ------------------------------------------------------------------- api
|
|
77
|
+
// Same origin always: the desk that served this file.
|
|
78
|
+
async function api(path, opts = {}) {
|
|
79
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
80
|
+
const res = await fetch(path + sep + "k=" + encodeURIComponent(key()), {
|
|
81
|
+
...opts,
|
|
82
|
+
headers: { ...(opts.headers || {}) },
|
|
78
83
|
});
|
|
84
|
+
const text = await res.text();
|
|
85
|
+
let body = null;
|
|
86
|
+
try { body = text ? JSON.parse(text) : null; } catch { /* not json */ }
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
throw new Error((body && (body.reason || body.error)) || `the desk answered ${res.status}`);
|
|
89
|
+
}
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
93
|
+
// ---------------------------------------------------------------- key state
|
|
94
|
+
let approverKey = null; // the non-extractable CryptoKey
|
|
95
|
+
let approverPublic = null; // its G... address, for display
|
|
96
|
+
let approverPubBytes = null; // its 32 raw bytes, for addSignature
|
|
97
|
+
|
|
98
|
+
async function loadKey() {
|
|
99
|
+
try { approverKey = await idbGet(); } catch { approverKey = null; }
|
|
100
|
+
if (!approverKey) return false;
|
|
101
|
+
// A non-extractable key cannot be asked for its own public bytes, so they were
|
|
102
|
+
// captured at enrolment and kept next to it. The address is for display; the
|
|
103
|
+
// raw bytes are what addSignature needs to attach the signature.
|
|
104
|
+
approverPublic = localStorage.getItem("paydlux.pub") || null;
|
|
105
|
+
const raw = localStorage.getItem("paydlux.pubraw") || "";
|
|
106
|
+
approverPubBytes = raw ? X.hexToBytes(raw) : null;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function enrol(seed) {
|
|
111
|
+
const trimmed = seed.trim();
|
|
112
|
+
// Import once as non-extractable (what we keep) and once as extractable
|
|
113
|
+
// purely to read back the public half, which the temporary key is then
|
|
114
|
+
// discarded for. The seed itself is never stored anywhere.
|
|
115
|
+
const { key: kp, pub, address } = await X.importSecretSeedPair(trimmed);
|
|
116
|
+
approverKey = kp;
|
|
117
|
+
approverPublic = address;
|
|
118
|
+
approverPubBytes = pub;
|
|
119
|
+
await idbPut(kp);
|
|
120
|
+
localStorage.setItem("paydlux.pub", address);
|
|
121
|
+
localStorage.setItem("paydlux.pubraw", X.hex(pub));
|
|
122
|
+
$("seed").value = "";
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ------------------------------------------------------------------ render
|
|
127
|
+
function paintKeyState() {
|
|
128
|
+
show("enrol", !approverKey);
|
|
129
|
+
show("ready", !!approverKey);
|
|
130
|
+
if (approverKey) set("who", approverPublic || "imported on this device");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function paintLinkState() {
|
|
134
|
+
const have = !!key();
|
|
135
|
+
show("nolink", !have);
|
|
136
|
+
show("queue", have);
|
|
137
|
+
if (!have) show("paste", false); // no link, no queue: nothing to fall back to yet
|
|
138
|
+
if (!have) $("link").value = "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ago = (iso) => {
|
|
142
|
+
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
|
143
|
+
if (s < 60) return Math.round(s) + "s ago";
|
|
144
|
+
if (s < 3600) return Math.round(s / 60) + "m ago";
|
|
145
|
+
return Math.round(s / 3600) + "h ago";
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const STATUS_TAG = {
|
|
149
|
+
pending: ["warn", "waiting"],
|
|
150
|
+
awaiting_phone: ["warn", "on your phone"],
|
|
151
|
+
settled: ["ok", "paid"],
|
|
152
|
+
denied: ["bad", "rejected"],
|
|
153
|
+
failed: ["bad", "failed"],
|
|
154
|
+
expired: ["bad", "expired"],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** A queue row. Built from DOM nodes so agent text can never be markup. */
|
|
158
|
+
function row(rec) {
|
|
159
|
+
const wrap = document.createElement("div");
|
|
160
|
+
wrap.className = "item" + (rec.status === "settled" || rec.status === "denied" ? " hist" : "");
|
|
161
|
+
|
|
162
|
+
const top = document.createElement("div");
|
|
163
|
+
top.className = "top";
|
|
164
|
+
const amt = document.createElement("div");
|
|
165
|
+
amt.className = "amt";
|
|
166
|
+
amt.textContent = `${rec.price} ${rec.token || ""}`;
|
|
167
|
+
const [, cls] = STATUS_TAG[rec.status] || ["warn", rec.status];
|
|
168
|
+
const tag = document.createElement("span");
|
|
169
|
+
tag.className = "tag " + cls;
|
|
170
|
+
tag.textContent = (STATUS_TAG[rec.status] || [null, rec.status])[1] || rec.status;
|
|
171
|
+
top.append(amt, tag);
|
|
172
|
+
|
|
173
|
+
const uri = document.createElement("div");
|
|
174
|
+
uri.className = "uri";
|
|
175
|
+
uri.textContent = rec.uri || "";
|
|
176
|
+
|
|
177
|
+
const wants = document.createElement("div");
|
|
178
|
+
wants.className = "wants";
|
|
179
|
+
wants.textContent = rec.wants || "";
|
|
180
|
+
|
|
181
|
+
const foot = document.createElement("div");
|
|
182
|
+
foot.className = "foot";
|
|
183
|
+
const when = document.createElement("span");
|
|
184
|
+
when.className = "ago";
|
|
185
|
+
when.textContent = ago(rec.at) + (rec.tx ? " \u00b7 " + rec.tx.slice(0, 12) + "\u2026" : "");
|
|
186
|
+
foot.append(when);
|
|
187
|
+
|
|
188
|
+
const actionable = rec.status === "pending" || rec.status === "awaiting_phone";
|
|
189
|
+
if (actionable) {
|
|
190
|
+
const btns = document.createElement("div");
|
|
191
|
+
btns.className = "btns";
|
|
192
|
+
const yes = document.createElement("button");
|
|
193
|
+
yes.textContent = "Review";
|
|
194
|
+
yes.addEventListener("click", () => openReview(rec.id));
|
|
195
|
+
const no = document.createElement("button");
|
|
196
|
+
no.className = "danger";
|
|
197
|
+
no.textContent = "Reject";
|
|
198
|
+
no.addEventListener("click", () => reject(rec.id));
|
|
199
|
+
btns.append(yes, no);
|
|
200
|
+
foot.append(btns);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
wrap.append(top, uri, wants, foot);
|
|
204
|
+
return wrap;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let queueTimer = null;
|
|
208
|
+
async function refreshQueue() {
|
|
209
|
+
if (!key()) return;
|
|
210
|
+
try {
|
|
211
|
+
const list = await api("/queue");
|
|
212
|
+
const live = list.filter((r) => r.status === "pending" || r.status === "awaiting_phone");
|
|
213
|
+
const past = list.filter((r) => r.status !== "pending" && r.status !== "awaiting_phone").slice(0, 5);
|
|
214
|
+
const box = $("qlist");
|
|
215
|
+
box.replaceChildren();
|
|
216
|
+
if (!live.length && !past.length) {
|
|
217
|
+
const e = document.createElement("div");
|
|
218
|
+
e.className = "empty";
|
|
219
|
+
e.textContent = "nothing waiting";
|
|
220
|
+
box.append(e);
|
|
104
221
|
}
|
|
222
|
+
live.forEach((r) => box.append(row(r)));
|
|
223
|
+
past.forEach((r) => box.append(row(r)));
|
|
224
|
+
// The hand-paste box is the fallback for a seller who sends a transaction by
|
|
225
|
+
// message instead of a link. It appears when there is nothing in the queue,
|
|
226
|
+
// so a fresh device never meets "paste the transaction" before its payments.
|
|
227
|
+
show("paste", !live.length);
|
|
228
|
+
set("qwhen", new Date().toLocaleTimeString());
|
|
229
|
+
err("qerr", "");
|
|
230
|
+
} catch (e) {
|
|
231
|
+
err("qerr", e.message + " — if this is new, open the seller's approval link again");
|
|
105
232
|
}
|
|
233
|
+
}
|
|
106
234
|
|
|
107
|
-
|
|
108
|
-
|
|
235
|
+
// ----------------------------------------------------------------- review
|
|
236
|
+
let current = null; // { id, xdr, hash, summary }
|
|
237
|
+
|
|
238
|
+
async function openReview(id) {
|
|
239
|
+
err("qerr", "");
|
|
240
|
+
try {
|
|
241
|
+
const rec = await api("/challenge/" + encodeURIComponent(id));
|
|
242
|
+
const env = X.parseEnvelope(rec.xdr);
|
|
109
243
|
const op = env.tx.ops[0];
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
244
|
+
if (env.tx.ops.length !== 1 || op.type !== X.OP_PAYMENT) {
|
|
245
|
+
throw new Error("this transaction is not a single payment \u2014 refusing to sign it");
|
|
246
|
+
}
|
|
247
|
+
const { hash } = await X.transactionHash(rec.xdr, X.NETWORK_ID[rec.network] || rec.network);
|
|
248
|
+
if (rec.hash && hash !== rec.hash) {
|
|
249
|
+
throw new Error("the transaction does not hash to what the desk advertised \u2014 refusing");
|
|
250
|
+
}
|
|
251
|
+
current = {
|
|
252
|
+
id, xdr: rec.xdr, hash,
|
|
253
|
+
amount: X.formatAmount(op.amount),
|
|
254
|
+
token: op.asset.code || "XLM",
|
|
255
|
+
issuer: op.asset.issuer || "",
|
|
256
|
+
payee: X.encodeAccountEd25519(op.destination.pub),
|
|
257
|
+
vault: X.encodeAccountEd25519(env.tx.source.pub),
|
|
258
|
+
memo: op.source ? "(payment from a secondary key)" : (rec.memo || ""),
|
|
259
|
+
deadline: rec.expiresAt || env.tx.cond?.timeBounds?.max || "",
|
|
260
|
+
network: rec.network,
|
|
261
|
+
wants: rec.wants || "",
|
|
262
|
+
};
|
|
263
|
+
set("amount", `${current.amount} ${current.token}`);
|
|
264
|
+
set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
|
|
265
|
+
set("payee", current.payee);
|
|
266
|
+
set("vault", current.vault);
|
|
267
|
+
set("wants", current.wants);
|
|
268
|
+
set("memo", current.memo);
|
|
269
|
+
set("deadline", current.deadline ? new Date(Number(current.deadline) * 1000).toLocaleString() : "none");
|
|
270
|
+
set("network", current.network === "public" ? "Stellar mainnet" : current.network);
|
|
271
|
+
set("hash", current.hash);
|
|
272
|
+
err("signErr", "");
|
|
273
|
+
show("review", true);
|
|
274
|
+
show("queue", false);
|
|
275
|
+
$("review").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
276
|
+
} catch (e) {
|
|
277
|
+
err("qerr", e.message);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
115
280
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
281
|
+
function backToQueue() {
|
|
282
|
+
current = null;
|
|
283
|
+
show("review", false);
|
|
284
|
+
show("result", false);
|
|
285
|
+
show("queue", !!key());
|
|
286
|
+
refreshQueue();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ------------------------------------------------------------------ sign
|
|
290
|
+
async function signAndSubmit() {
|
|
291
|
+
if (!current) return;
|
|
292
|
+
if (!approverKey) { err("signErr", "no approver key on this device"); return; }
|
|
293
|
+
const button = $("approve");
|
|
294
|
+
button.disabled = true;
|
|
295
|
+
try {
|
|
296
|
+
// Re-hash at the moment of signing. What is signed is what was on screen.
|
|
297
|
+
const { hash } = await X.transactionHash(current.xdr, X.NETWORK_ID[current.network] || current.network);
|
|
298
|
+
if (hash !== current.hash) throw new Error("the transaction changed under you \u2014 nothing was signed");
|
|
299
|
+
const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", approverKey, X.signaturePreimage(hash)));
|
|
300
|
+
if (!approverPubBytes) throw new Error("this device lost the approver's public key — re-import it");
|
|
301
|
+
const signed = X.addSignature(current.xdr, sig, approverPubBytes);
|
|
302
|
+
const out = await api("/signed/" + encodeURIComponent(current.id), {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "content-type": "application/json" },
|
|
305
|
+
body: JSON.stringify({ signedXdr: signed }),
|
|
306
|
+
});
|
|
307
|
+
set("signed", signed);
|
|
308
|
+
const tag = $("resultTag");
|
|
309
|
+
tag.className = "tag " + (out.status === "settled" ? "ok" : "bad");
|
|
310
|
+
tag.textContent = out.status === "settled" ? "Paid" : out.status;
|
|
311
|
+
if (out.tx) set("resultLabel", `Signed \u00b7 ledger tx ${out.tx}`);
|
|
312
|
+
if (out.reason) set("resultLabel", out.reason);
|
|
313
|
+
show("result", true);
|
|
314
|
+
show("review", false);
|
|
315
|
+
} catch (e) {
|
|
316
|
+
err("signErr", e.message);
|
|
317
|
+
} finally {
|
|
318
|
+
button.disabled = false;
|
|
137
319
|
}
|
|
320
|
+
}
|
|
138
321
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
322
|
+
async function reject(id) {
|
|
323
|
+
try {
|
|
324
|
+
await api("/deny/" + encodeURIComponent(id), { method: "POST" });
|
|
325
|
+
refreshQueue();
|
|
326
|
+
} catch (e) {
|
|
327
|
+
err("qerr", e.message);
|
|
144
328
|
}
|
|
329
|
+
}
|
|
145
330
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
331
|
+
// ------------------------------------------------------- hand-pasted xdr
|
|
332
|
+
// For a seller who sends the pending transaction by hand. There is no trusted
|
|
333
|
+
// source for it, so the app makes the human type back the hash the seller
|
|
334
|
+
// quoted: signing then proves the human saw the same bytes, not a swapped one.
|
|
335
|
+
async function readPasted() {
|
|
336
|
+
err("readErr", "");
|
|
337
|
+
try {
|
|
338
|
+
if (!approverKey || !approverPubBytes) throw new Error("import the approver key first");
|
|
339
|
+
const xdr = $("xdr").value.trim();
|
|
340
|
+
if (!xdr) throw new Error("paste the transaction first");
|
|
341
|
+
const env = X.parseEnvelope(xdr);
|
|
342
|
+
if (env.tx.ops.length !== 1 || env.tx.ops[0].type !== X.OP_PAYMENT) {
|
|
343
|
+
throw new Error("not a single payment \u2014 refusing");
|
|
154
344
|
}
|
|
155
|
-
|
|
345
|
+
const op = env.tx.ops[0];
|
|
346
|
+
const local = location.hostname === "localhost" || /^127\./.test(location.hostname);
|
|
347
|
+
const net = local ? "testnet" : "public";
|
|
348
|
+
const { hash } = await X.transactionHash(xdr, X.NETWORK_ID[net]);
|
|
349
|
+
// No trusted source, so the human proves they saw these exact bytes by
|
|
350
|
+
// typing back the hash the seller quoted. A swapped transaction hashes
|
|
351
|
+
// differently and this check fails.
|
|
352
|
+
const want = prompt(
|
|
353
|
+
`Network: ${net}\n` +
|
|
354
|
+
`This pays ${X.formatAmount(op.amount)} ${op.asset.code || "XLM"} to ` +
|
|
355
|
+
`${X.encodeAccountEd25519(op.destination.pub)}.\n\n` +
|
|
356
|
+
`Type the first 12 characters of the hash the seller quoted to confirm you are ` +
|
|
357
|
+
`signing the same transaction.`);
|
|
358
|
+
if (!want) return;
|
|
359
|
+
if (want.trim().toLowerCase() !== hash.slice(0, 12)) {
|
|
360
|
+
throw new Error("that is not the hash the seller quoted \u2014 nothing was signed");
|
|
361
|
+
}
|
|
362
|
+
const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", approverKey, X.signaturePreimage(hash)));
|
|
363
|
+
set("signed", X.addSignature(xdr, sig, approverPubBytes));
|
|
364
|
+
set("resultLabel", "Signed \u2014 send this back to the seller");
|
|
365
|
+
$("resultTag").className = "tag ok";
|
|
366
|
+
$("resultTag").textContent = "Signed";
|
|
367
|
+
show("result", true);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
err("readErr", e.message);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
156
372
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
373
|
+
// ------------------------------------------------------------------ boot
|
|
374
|
+
async function boot() {
|
|
375
|
+
// A deep link from the seller's desk saves the approval link automatically.
|
|
376
|
+
const here = new URL(location.href);
|
|
377
|
+
if (here.searchParams.get("k")) {
|
|
378
|
+
const u = new URL(location.pathname, location.origin);
|
|
379
|
+
const p = new URLSearchParams();
|
|
380
|
+
for (const [k2, v] of here.searchParams) p.set(k2, v);
|
|
381
|
+
localStorage.setItem(LINK, u.pathname + "?" + p.toString());
|
|
164
382
|
}
|
|
165
383
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (!signingKey) {
|
|
170
|
-
$("signErr").textContent = "Import the approver key first.";
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (!pending) return;
|
|
174
|
-
|
|
175
|
-
const picked = document.querySelector("input[name=net]:checked");
|
|
176
|
-
const which = picked ? picked.value : "testnet";
|
|
177
|
-
const network = which === "testnet" ? X.NETWORK_ID.testnet : X.NETWORK_ID.public;
|
|
178
|
-
|
|
179
|
-
// A pasted transaction has no trusted source, so the human proves they were
|
|
180
|
-
// shown this exact one by typing its hash back. A transaction fetched from
|
|
181
|
-
// the desk over the portal key is already bound to the desk's own record,
|
|
182
|
-
// which the desk re-checks before submitting.
|
|
183
|
-
if (!pending.fromDesk) {
|
|
184
|
-
const quoted = prompt("Type the first 16 characters of the hash the seller quoted you:");
|
|
185
|
-
if (!quoted) return;
|
|
186
|
-
const q = quoted.trim().toLowerCase();
|
|
187
|
-
if (q !== pending.hashes.testnet.slice(0, 16) && q !== pending.hashes.public.slice(0, 16)) {
|
|
188
|
-
$("signErr").textContent = "That hash matches neither. Nothing was signed.";
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
384
|
+
await loadKey();
|
|
385
|
+
paintKeyState();
|
|
386
|
+
paintLinkState();
|
|
192
387
|
|
|
388
|
+
$("enrolBtn").addEventListener("click", async () => {
|
|
389
|
+
err("enrolErr", "");
|
|
193
390
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const sig = new Uint8Array(await crypto.subtle.sign(
|
|
198
|
-
"Ed25519", signingKey, X.signaturePreimage(hash)));
|
|
199
|
-
const rec = await loadKey();
|
|
200
|
-
const signed = X.addSignature(pending.xdrText, sig, base64urlToBytes(rec.x));
|
|
201
|
-
|
|
202
|
-
if (pending.fromDesk) {
|
|
203
|
-
const res = await fetch("/signed/" + encodeURIComponent(pending.id) +
|
|
204
|
-
(pending.k ? "?k=" + encodeURIComponent(pending.k) : ""), {
|
|
205
|
-
method: "POST",
|
|
206
|
-
headers: { "content-type": "application/json" },
|
|
207
|
-
body: JSON.stringify({ signedXdr: signed }),
|
|
208
|
-
});
|
|
209
|
-
const out = await res.json().catch(() => ({}));
|
|
210
|
-
if (out.status === "settled") {
|
|
211
|
-
// Show the ledger hash and keep the panel up: this is the only proof
|
|
212
|
-
// the human gets that the payment actually landed. reset() would hide
|
|
213
|
-
// it again, so clear the form by hand instead.
|
|
214
|
-
pending = null;
|
|
215
|
-
$("xdr").value = "";
|
|
216
|
-
$("review").classList.add("hidden");
|
|
217
|
-
$("signed").textContent = "settled on ledger\n" + (out.tx || "");
|
|
218
|
-
$("result").classList.remove("hidden");
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
$("signErr").textContent = "the desk refused it: " +
|
|
222
|
-
(out.reason || out.error || out.status || res.status);
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
$("signed").textContent = signed;
|
|
226
|
-
$("result").classList.remove("hidden");
|
|
391
|
+
await enrol($("seed").value);
|
|
392
|
+
paintKeyState();
|
|
393
|
+
refreshQueue();
|
|
227
394
|
} catch (e) {
|
|
228
|
-
|
|
395
|
+
err("enrolErr", e.message);
|
|
229
396
|
}
|
|
230
397
|
});
|
|
231
|
-
|
|
398
|
+
$("forget").addEventListener("click", async () => {
|
|
399
|
+
await idbDel();
|
|
400
|
+
localStorage.removeItem("paydlux.pub");
|
|
401
|
+
approverKey = null; approverPublic = null;
|
|
402
|
+
paintKeyState();
|
|
403
|
+
});
|
|
404
|
+
$("linkBtn").addEventListener("click", () => {
|
|
405
|
+
const raw = $("link").value.trim();
|
|
406
|
+
if (!raw) return;
|
|
407
|
+
const u = new URL(raw, location.origin);
|
|
408
|
+
if (!u.searchParams.get("k")) { err("linkErr", "that link has no approval key in it"); return; }
|
|
409
|
+
localStorage.setItem(LINK, u.pathname + "?" + u.searchParams.toString());
|
|
410
|
+
err("linkErr", "");
|
|
411
|
+
paintLinkState();
|
|
412
|
+
refreshQueue();
|
|
413
|
+
});
|
|
414
|
+
$("approve").addEventListener("click", signAndSubmit);
|
|
415
|
+
$("reject").addEventListener("click", () => current && reject(current.id) && backToQueue());
|
|
416
|
+
$("again").addEventListener("click", backToQueue);
|
|
417
|
+
$("read").addEventListener("click", readPasted);
|
|
232
418
|
$("copy").addEventListener("click", async () => {
|
|
233
|
-
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
} catch (e) {
|
|
237
|
-
$("copy").textContent = "Select the text above and copy it manually";
|
|
238
|
-
}
|
|
419
|
+
try { await navigator.clipboard.writeText($("signed").textContent); $("copy").textContent = "Copied"; }
|
|
420
|
+
catch { $("copy").textContent = "Copy failed \u2014 select the text"; }
|
|
421
|
+
setTimeout(() => { $("copy").textContent = "Copy"; }, 1500);
|
|
239
422
|
});
|
|
240
423
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
} catch (e) { /* enrolment UI stays up */ }
|
|
251
|
-
await loadFromDesk();
|
|
252
|
-
})();
|
|
253
|
-
})();
|
|
424
|
+
if (key()) {
|
|
425
|
+
await refreshQueue();
|
|
426
|
+
queueTimer = setInterval(refreshQueue, 4000);
|
|
427
|
+
const id = new URLSearchParams(localStorage.getItem(LINK)?.split("?")[1] || "").get("id");
|
|
428
|
+
if (id) openReview(id);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
boot().catch((e) => { err("qerr", e.message); });
|
|
Binary file
|
|
Binary file
|
package/phone-app/index.html
CHANGED
|
@@ -4,16 +4,22 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
6
|
<meta name="theme-color" content="#0b0f14">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
9
|
+
<meta name="apple-mobile-web-app-title" content="Paydlux">
|
|
7
10
|
<title>Paydlux Approver</title>
|
|
11
|
+
<link rel="manifest" href="manifest.webmanifest">
|
|
12
|
+
<link rel="icon" href="icon-192.png" sizes="192x192">
|
|
13
|
+
<link rel="apple-touch-icon" href="icon-192.png">
|
|
8
14
|
<!--
|
|
9
|
-
The approver half of a Paydlux vault.
|
|
15
|
+
The approver half of a Paydlux vault: a phone app, not a page you paste into.
|
|
10
16
|
|
|
11
|
-
This
|
|
12
|
-
It
|
|
13
|
-
the transaction hash. Signing is a purely local operation: no
|
|
14
|
-
change what was signed.
|
|
17
|
+
This app holds ONE key: the approver's. It cannot spend anything by itself.
|
|
18
|
+
It lists the payments waiting on you, shows a human what they are approving,
|
|
19
|
+
and signs the transaction hash. Signing is a purely local operation: no
|
|
20
|
+
network call can change what was signed.
|
|
15
21
|
|
|
16
|
-
The
|
|
22
|
+
The app talks to one origin only — the desk that served it — to fetch the
|
|
17
23
|
pending transaction and hand back the signature. connect-src 'self' in the
|
|
18
24
|
desk's Content-Security-Policy enforces that.
|
|
19
25
|
|
|
@@ -28,7 +34,10 @@
|
|
|
28
34
|
transaction without reading it. The human reading the amount and payee is part
|
|
29
35
|
of the security model, not a UX step.
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
The static shell (this file, app.js, xdr.js, the icons) carries no secrets and
|
|
38
|
+
no payment data, so the desk serves it without the portal key — that is what
|
|
39
|
+
lets it be installed to a home screen. Every route that carries data or moves
|
|
40
|
+
money still needs the key.
|
|
32
41
|
-->
|
|
33
42
|
<style>
|
|
34
43
|
:root {
|
|
@@ -67,13 +76,30 @@
|
|
|
67
76
|
.tag { display:inline-block; font-size:11px; padding:3px 8px; border-radius:999px; letter-spacing:.06em; text-transform:uppercase }
|
|
68
77
|
.tag.ok { background:rgba(61,220,151,.14); color:var(--ok) }
|
|
69
78
|
.tag.warn { background:rgba(255,180,84,.14); color:var(--warn) }
|
|
70
|
-
.tag.bad { background:rgba(
|
|
79
|
+
.tag.bad { background:rgba(257,107,107,.14); color:var(--bad) }
|
|
71
80
|
.err { color:var(--bad); font-size:13px; margin-top:8px; word-break:break-word }
|
|
72
81
|
.hint { color:var(--dim); font-size:12px; margin-top:8px }
|
|
73
82
|
.hidden { display:none }
|
|
74
83
|
.out { background:#0d131a; border:1px solid var(--line); border-radius:10px; padding:10px;
|
|
75
84
|
font:12px/1.4 ui-monospace,Menlo,monospace; word-break:break-all; max-height:180px; overflow:auto }
|
|
76
85
|
.who { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--dim); word-break:break-all }
|
|
86
|
+
|
|
87
|
+
/* ------------------------------------------------------------ queue */
|
|
88
|
+
.qhead { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:6px }
|
|
89
|
+
.qhead h2 { font-size:13px; margin:0; text-transform:uppercase; letter-spacing:.06em; color:var(--dim) }
|
|
90
|
+
.qhead .when { font-size:11px; color:var(--dim) }
|
|
91
|
+
.item { border:1px solid var(--line); border-radius:12px; padding:12px; margin-top:10px; background:#0d131a }
|
|
92
|
+
.item .top { display:flex; justify-content:space-between; align-items:baseline; gap:10px }
|
|
93
|
+
.item .amt { font-size:22px; font-weight:800; font-variant-numeric:tabular-nums }
|
|
94
|
+
.item .uri { font-size:12px; color:var(--dim); word-break:break-all; margin-top:2px }
|
|
95
|
+
.item .wants { font-size:13px; margin-top:6px; word-break:break-word }
|
|
96
|
+
.item .foot { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-top:8px }
|
|
97
|
+
.item .ago { font-size:11px; color:var(--dim) }
|
|
98
|
+
.item .btns { display:flex; gap:8px }
|
|
99
|
+
.item .btns button { width:auto; margin:0; padding:9px 16px; font-size:14px }
|
|
100
|
+
.empty { color:var(--dim); font-size:14px; padding:14px 2px }
|
|
101
|
+
.hist { opacity:.62 }
|
|
102
|
+
.hist .amt { font-size:16px }
|
|
77
103
|
</style>
|
|
78
104
|
</head>
|
|
79
105
|
<body>
|
|
@@ -104,21 +130,38 @@
|
|
|
104
130
|
<button class="ghost" id="forget">Remove key from this device</button>
|
|
105
131
|
</div>
|
|
106
132
|
|
|
107
|
-
<!--
|
|
108
|
-
<div class="panel">
|
|
109
|
-
<
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
133
|
+
<!-- -------------------------------------------------------- not linked -->
|
|
134
|
+
<div class="panel hidden" id="nolink">
|
|
135
|
+
<div class="tag bad">No approval link</div>
|
|
136
|
+
<div class="hint" style="margin-top:10px">
|
|
137
|
+
This app shows payments only to whoever holds the seller's approval link. Open the
|
|
138
|
+
link the seller sent you once — the app then remembers it on this device.
|
|
139
|
+
</div>
|
|
140
|
+
<label for="link">Approval link</label>
|
|
141
|
+
<input id="link" type="url" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
142
|
+
placeholder="https://desk.paydlux.com/phone/?k=...">
|
|
143
|
+
<button id="linkBtn">Use this link</button>
|
|
144
|
+
<div class="err" id="linkErr"></div>
|
|
114
145
|
</div>
|
|
115
146
|
|
|
147
|
+
<!-- ------------------------------------------------------------- queue -->
|
|
148
|
+
<div class="panel hidden" id="queue">
|
|
149
|
+
<div class="qhead">
|
|
150
|
+
<h2>Waiting for you</h2>
|
|
151
|
+
<span class="when" id="qwhen"></span>
|
|
152
|
+
</div>
|
|
153
|
+
<div id="qlist"><div class="empty">loading…</div></div>
|
|
154
|
+
<div class="err" id="qerr"></div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- ----------------------------------------------------------- review -->
|
|
116
158
|
<div class="panel hidden" id="review">
|
|
117
159
|
<div class="amount" id="amount"></div>
|
|
118
160
|
<dl class="kv">
|
|
119
161
|
<dt>Token</dt><dd id="token"></dd>
|
|
120
162
|
<dt>Payee</dt><dd id="payee"></dd>
|
|
121
163
|
<dt>From</dt><dd id="vault"></dd>
|
|
164
|
+
<dt>Wants</dt><dd id="wants"></dd>
|
|
122
165
|
<dt>Reference</dt><dd id="memo"></dd>
|
|
123
166
|
<dt>Deadline</dt><dd id="deadline"></dd>
|
|
124
167
|
<dt>Network</dt><dd id="network"></dd>
|
|
@@ -133,10 +176,30 @@
|
|
|
133
176
|
<div class="err" id="signErr"></div>
|
|
134
177
|
</div>
|
|
135
178
|
|
|
136
|
-
<!--
|
|
179
|
+
<!-- --------------------------------------------------------- pasted xdr -->
|
|
180
|
+
<!-- Hidden until there is nothing in the queue. A human who just imported a key
|
|
181
|
+
should see their payments, not a box asking them to paste 400 characters of
|
|
182
|
+
base64 they were never asked for. This path is the fallback for when the
|
|
183
|
+
seller sends a transaction by message instead of a link. -->
|
|
184
|
+
<div class="panel hidden" id="paste">
|
|
185
|
+
<details>
|
|
186
|
+
<summary style="font-size:13px;color:var(--dim)">Sign a transaction the seller sent by hand</summary>
|
|
187
|
+
<label for="xdr">Pending transaction (XDR)</label>
|
|
188
|
+
<textarea id="xdr" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
189
|
+
placeholder="Paste the transaction the seller sent you"></textarea>
|
|
190
|
+
<button id="read">Read it</button>
|
|
191
|
+
<div class="hint">
|
|
192
|
+
A hand-sent transaction has no trusted source, so the app will make you type back
|
|
193
|
+
the hash the seller quoted before it signs.
|
|
194
|
+
</div>
|
|
195
|
+
<div class="err" id="readErr"></div>
|
|
196
|
+
</details>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<!-- ------------------------------------------------------------ result -->
|
|
137
200
|
<div class="panel hidden" id="result">
|
|
138
|
-
<div class="tag ok">Signed</div>
|
|
139
|
-
<label>Signed transaction — send this back to the seller</label>
|
|
201
|
+
<div class="tag ok" id="resultTag">Signed</div>
|
|
202
|
+
<label id="resultLabel">Signed transaction — send this back to the seller</label>
|
|
140
203
|
<div class="out" id="signed"></div>
|
|
141
204
|
<button id="copy">Copy</button>
|
|
142
205
|
<button class="ghost" id="again">Done</button>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Paydlux Approver",
|
|
3
|
+
"short_name": "Paydlux",
|
|
4
|
+
"description": "Signs Paydlux payment approvals on this device. The approver key never leaves it.",
|
|
5
|
+
"start_url": "/phone/",
|
|
6
|
+
"scope": "/phone/",
|
|
7
|
+
"display": "standalone",
|
|
8
|
+
"orientation": "portrait",
|
|
9
|
+
"background_color": "#0b0f14",
|
|
10
|
+
"theme_color": "#0b0f14",
|
|
11
|
+
"icons": [
|
|
12
|
+
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
13
|
+
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
|
14
|
+
]
|
|
15
|
+
}
|
package/phone-app/xdr.js
CHANGED
|
@@ -172,6 +172,49 @@
|
|
|
172
172
|
* The public half is captured at enrolment and stored separately.
|
|
173
173
|
*/
|
|
174
174
|
async function importSecretSeed(seedStr) {
|
|
175
|
+
const pkcs8 = pkcs8FromSeed(seedStr);
|
|
176
|
+
const privateKey = await crypto.subtle.importKey(
|
|
177
|
+
"pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
|
|
178
|
+
pkcs8.fill(0); // drop the plaintext copy as soon as we can
|
|
179
|
+
return privateKey;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Import a seed AND read back its public half, in one step.
|
|
184
|
+
*
|
|
185
|
+
* A non-extractable key cannot be asked for its own public bytes, and the
|
|
186
|
+
* approver app needs those bytes to add its signature to a transaction. So the
|
|
187
|
+
* seed is imported a second time as an extractable key purely to export the
|
|
188
|
+
* 32-byte public half; that temporary key is discarded immediately and never
|
|
189
|
+
* stored. What the app keeps is the non-extractable key plus a public value
|
|
190
|
+
* that is public anyway.
|
|
191
|
+
*
|
|
192
|
+
* The export goes through JWK rather than "raw": WebCrypto defines raw only for
|
|
193
|
+
* OKP *public* keys, so exporting raw from a private key is refused (verified
|
|
194
|
+
* on Node 26). The JWK form of an Ed25519 private key carries the public half
|
|
195
|
+
* in `x`, base64url.
|
|
196
|
+
*/
|
|
197
|
+
async function importSecretSeedPair(seedStr) {
|
|
198
|
+
const key = await importSecretSeed(seedStr);
|
|
199
|
+
const tmp = await crypto.subtle.importKey(
|
|
200
|
+
"pkcs8", pkcs8FromSeed(seedStr), { name: "Ed25519" }, true, ["sign"]);
|
|
201
|
+
let pub;
|
|
202
|
+
try {
|
|
203
|
+
const jwk = await crypto.subtle.exportKey("jwk", tmp);
|
|
204
|
+
pub = base64UrlToBytes(jwk.x);
|
|
205
|
+
} catch {
|
|
206
|
+
pub = new Uint8Array(await crypto.subtle.exportKey("raw", tmp));
|
|
207
|
+
}
|
|
208
|
+
if (pub.length !== 32) throw new Error("unexpected public key length " + pub.length);
|
|
209
|
+
return { key, pub, address: encodeAccountEd25519(pub) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function base64UrlToBytes(s) {
|
|
213
|
+
if (typeof s !== "string" || !s) throw new Error("missing public key in the exported key");
|
|
214
|
+
return base64ToBytes(s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - s.length % 4) % 4));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pkcs8FromSeed(seedStr) {
|
|
175
218
|
const seed = decodeSecretSeed(seedStr);
|
|
176
219
|
const pkcs8 = new Uint8Array(16 + 32);
|
|
177
220
|
pkcs8.set([
|
|
@@ -179,10 +222,7 @@
|
|
|
179
222
|
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
|
180
223
|
], 0);
|
|
181
224
|
pkcs8.set(seed, 16);
|
|
182
|
-
|
|
183
|
-
"pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
|
|
184
|
-
seed.fill(0); // drop the plaintext copy as soon as we can
|
|
185
|
-
return privateKey;
|
|
225
|
+
return pkcs8;
|
|
186
226
|
}
|
|
187
227
|
|
|
188
228
|
// ------------------------------------------------------------------ parsing
|
|
@@ -429,7 +469,7 @@
|
|
|
429
469
|
parseEnvelope, transactionHash, addSignature, formatAmount,
|
|
430
470
|
hex, hexToBytes, signaturePreimage,
|
|
431
471
|
encodeAccountEd25519, decodeAccountEd25519,
|
|
432
|
-
decodeSecretSeed, encodeSecretSeed, importSecretSeed,
|
|
472
|
+
decodeSecretSeed, encodeSecretSeed, importSecretSeed, importSecretSeedPair,
|
|
433
473
|
NETWORK_ID, crc16Xmodem,
|
|
434
474
|
ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
|
|
435
475
|
};
|