@paydlux/agent 0.1.8 → 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 +58 -3
- 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/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); });
|