@paydlux/agent 0.1.9 → 0.1.11
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/agent.d.ts +8 -0
- package/dist/agent.js +1 -1
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +165 -8
- package/dist/desk.d.ts +50 -0
- package/dist/desk.js +215 -56
- package/dist/gateway.d.ts +10 -2
- package/dist/gateway.js +20 -1
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +2 -2
- package/package.json +10 -2
- package/phone-app/app.js +653 -224
- package/phone-app/icon-192.png +0 -0
- package/phone-app/icon-512.png +0 -0
- package/phone-app/index.html +195 -53
- package/phone-app/manifest.webmanifest +19 -0
- package/phone-app/xdr.js +45 -5
package/phone-app/app.js
CHANGED
|
@@ -1,253 +1,682 @@
|
|
|
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
|
-
|
|
71
|
-
|
|
26
|
+
// A short buzz on the two moments that matter. iOS ignores vibrate(); Android
|
|
27
|
+
// and desktop honour it. Guarded so it is never an uncaught error.
|
|
28
|
+
const buzz = (ms) => { try { navigator.vibrate?.(ms); } catch { /* no haptics */ } };
|
|
29
|
+
|
|
30
|
+
// ------------------------------------------------------------------ storage
|
|
31
|
+
// The approver key goes in IndexedDB, not localStorage. localStorage can only
|
|
32
|
+
// hold strings, which would force us to re-encode the key as something
|
|
33
|
+
// readable; IndexedDB stores the CryptoKey object itself, and a
|
|
34
|
+
// non-extractable key has no readable form to store.
|
|
35
|
+
const DB = "paydlux-approver";
|
|
36
|
+
const idb = () => new Promise((res, rej) => {
|
|
37
|
+
const open = indexedDB.open(DB, 1);
|
|
38
|
+
open.onupgradeneeded = () => open.result.createObjectStore("keys");
|
|
39
|
+
open.onsuccess = () => res(open.result);
|
|
40
|
+
open.onerror = () => rej(open.error);
|
|
41
|
+
});
|
|
42
|
+
async function idbPut(value) {
|
|
43
|
+
const db = await idb();
|
|
44
|
+
return new Promise((res, rej) => {
|
|
45
|
+
const t = db.transaction("keys", "readwrite");
|
|
46
|
+
t.objectStore("keys").put(value, "approver");
|
|
47
|
+
t.oncomplete = () => res();
|
|
48
|
+
t.onerror = () => rej(t.error);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function idbGet() {
|
|
52
|
+
const db = await idb();
|
|
53
|
+
return new Promise((res, rej) => {
|
|
54
|
+
const t = db.transaction("keys", "readonly");
|
|
55
|
+
const g = t.objectStore("keys").get("approver");
|
|
56
|
+
g.onsuccess = () => res(g.result ?? null);
|
|
57
|
+
g.onerror = () => rej(g.error);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async function idbDel() {
|
|
61
|
+
const db = await idb();
|
|
62
|
+
return new Promise((res, rej) => {
|
|
63
|
+
const t = db.transaction("keys", "readwrite");
|
|
64
|
+
t.objectStore("keys").delete("approver");
|
|
65
|
+
t.oncomplete = () => res();
|
|
66
|
+
t.onerror = () => rej(t.error);
|
|
72
67
|
});
|
|
68
|
+
}
|
|
73
69
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
// The approval link carries the seller's portal key. It is the thing that makes
|
|
71
|
+
// this app useful, so it is kept on the device after the seller opens it once.
|
|
72
|
+
const LINK = "paydlux.link";
|
|
73
|
+
const linkParams = () => {
|
|
74
|
+
const raw = localStorage.getItem(LINK) || "";
|
|
75
|
+
const u = new URL(raw, location.href);
|
|
76
|
+
return u.searchParams;
|
|
77
|
+
};
|
|
78
|
+
const key = () => linkParams().get("k") || "";
|
|
79
|
+
|
|
80
|
+
// ------------------------------------------------------------------- api
|
|
81
|
+
// Same origin always: the desk that served this file.
|
|
82
|
+
async function api(path, opts = {}) {
|
|
83
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
84
|
+
const res = await fetch(path + sep + "k=" + encodeURIComponent(key()), {
|
|
85
|
+
...opts,
|
|
86
|
+
headers: { ...(opts.headers || {}) },
|
|
78
87
|
});
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
let body = null;
|
|
90
|
+
try { body = text ? JSON.parse(text) : null; } catch { /* not json */ }
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
throw new Error((body && (body.reason || body.error)) || `the desk answered ${res.status}`);
|
|
93
|
+
}
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
79
96
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
// ---------------------------------------------------------------- key state
|
|
98
|
+
let approverKey = null; // the non-extractable CryptoKey (browser path)
|
|
99
|
+
let approverPublic = null; // its G... address, for display
|
|
100
|
+
let approverPubBytes = null; // its 32 raw bytes, for addSignature
|
|
101
|
+
|
|
102
|
+
// The native app (ios/) exposes a Keychain key behind Face ID through a
|
|
103
|
+
// WKWebView script bridge. When it is present it wins: the key is in the
|
|
104
|
+
// iPhone's hardware-protected Keychain, marked ThisDeviceOnly, and every sign
|
|
105
|
+
// is gated by a biometric. The WebCrypto path below stays for the browser, and
|
|
106
|
+
// the two are never both used — a payment is signed by exactly one key.
|
|
107
|
+
//
|
|
108
|
+
// WebKit gives a page registered against WKScriptMessageHandlerWithReply a
|
|
109
|
+
// promise-returning postMessage, so the round trip is a plain await. No
|
|
110
|
+
// callback registry, no global to collide with.
|
|
111
|
+
const native = {
|
|
112
|
+
available: () => !!(globalThis.webkit?.messageHandlers?.sign
|
|
113
|
+
&& globalThis.webkit?.messageHandlers?.publicKey),
|
|
114
|
+
publicKey: async () => {
|
|
115
|
+
const out = await globalThis.webkit.messageHandlers.publicKey.postMessage({});
|
|
116
|
+
return typeof out === "string" && out ? out : null;
|
|
117
|
+
},
|
|
118
|
+
sign: async (preimageHex) => {
|
|
119
|
+
const out = await globalThis.webkit.messageHandlers.sign.postMessage({ preimage: preimageHex });
|
|
120
|
+
if (out && out.signature) return out.signature;
|
|
121
|
+
throw new Error((out && out.error) || "the native signer refused");
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
async function loadKey() {
|
|
126
|
+
if (native.available()) {
|
|
127
|
+
// The native key needs no import: it is already on the device, behind Face
|
|
128
|
+
// ID. The public half is read from the bridge so the review screen can show
|
|
129
|
+
// which key will sign, and addSignature can attach the result.
|
|
93
130
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
$("readErr").textContent = "";
|
|
102
|
-
} catch (e) {
|
|
103
|
-
$("readErr").textContent = "could not load " + id + ": " + e.message;
|
|
104
|
-
}
|
|
131
|
+
const hex = await native.publicKey();
|
|
132
|
+
if (hex) {
|
|
133
|
+
approverPubBytes = X.hexToBytes(hex);
|
|
134
|
+
approverPublic = X.encodeAccountEd25519(approverPubBytes);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
} catch { /* fall through to the browser key */ }
|
|
105
138
|
}
|
|
139
|
+
try { approverKey = await idbGet(); } catch { approverKey = null; }
|
|
140
|
+
if (!approverKey) return false;
|
|
141
|
+
// A non-extractable key cannot be asked for its own public bytes, so they were
|
|
142
|
+
// captured at enrolment and kept next to it. The address is for display; the
|
|
143
|
+
// raw bytes are what addSignature needs to attach the signature.
|
|
144
|
+
approverPublic = localStorage.getItem("paydlux.pub") || null;
|
|
145
|
+
const raw = localStorage.getItem("paydlux.pubraw") || "";
|
|
146
|
+
approverPubBytes = raw ? X.hexToBytes(raw) : null;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
106
149
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
150
|
+
// The one place a signature is produced. Both paths end here, so the audit
|
|
151
|
+
// question "what can sign?" has one answer: this function, over a hash this file
|
|
152
|
+
// computed, after the human held the button.
|
|
153
|
+
async function signPreimage(preimageHex) {
|
|
154
|
+
// One return type for both paths: 64 raw bytes. The bridge answers in hex
|
|
155
|
+
// because a script message cannot carry a byte array, and a half-converted
|
|
156
|
+
// signature is the kind of bug that shows up as an invalid transaction rather
|
|
157
|
+
// than an error.
|
|
158
|
+
if (native.available()) {
|
|
159
|
+
const hex = await native.sign(preimageHex);
|
|
160
|
+
return X.hexToBytes(hex);
|
|
161
|
+
}
|
|
162
|
+
if (!approverKey) throw new Error("no approver key on this device");
|
|
163
|
+
return new Uint8Array(
|
|
164
|
+
await crypto.subtle.sign("Ed25519", approverKey, X.hexToBytes(preimageHex)));
|
|
165
|
+
}
|
|
115
166
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
167
|
+
async function enrol(seed) {
|
|
168
|
+
const trimmed = seed.trim();
|
|
169
|
+
// Import once as non-extractable (what we keep) and once as extractable
|
|
170
|
+
// purely to read back the public half, which the temporary key is then
|
|
171
|
+
// discarded for. The seed itself is never stored anywhere.
|
|
172
|
+
const { key: kp, pub, address } = await X.importSecretSeedPair(trimmed);
|
|
173
|
+
approverKey = kp;
|
|
174
|
+
approverPublic = address;
|
|
175
|
+
approverPubBytes = pub;
|
|
176
|
+
await idbPut(kp);
|
|
177
|
+
localStorage.setItem("paydlux.pub", address);
|
|
178
|
+
localStorage.setItem("paydlux.pubraw", X.hex(pub));
|
|
179
|
+
$("seed").value = "";
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------------ render
|
|
184
|
+
function paintKeyState() {
|
|
185
|
+
const have = !!approverKey || native.available();
|
|
186
|
+
show("enrol", !have);
|
|
187
|
+
show("ready", have);
|
|
188
|
+
if (have) {
|
|
189
|
+
set("who", approverPublic || "held by this app");
|
|
190
|
+
// In the native app the seed lives in the Keychain and the page has no way
|
|
191
|
+
// to write it, so the paste box would be a lie.
|
|
192
|
+
if (native.available()) show("seedRow", false);
|
|
137
193
|
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function paintLinkState() {
|
|
197
|
+
const have = !!key();
|
|
198
|
+
show("nolink", !have);
|
|
199
|
+
show("queue", have);
|
|
200
|
+
if (!have) show("paste", false); // no link, no queue: nothing to fall back to yet
|
|
201
|
+
if (!have) $("link").value = "";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ago = (iso) => {
|
|
205
|
+
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
|
206
|
+
if (s < 60) return Math.round(s) + "s ago";
|
|
207
|
+
if (s < 3600) return Math.round(s / 60) + "m ago";
|
|
208
|
+
return Math.round(s / 3600) + "h ago";
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const STATUS_TAG = {
|
|
212
|
+
pending: ["warn", "waiting"],
|
|
213
|
+
awaiting_phone: ["warn", "on your phone"],
|
|
214
|
+
settled: ["ok", "paid"],
|
|
215
|
+
denied: ["bad", "rejected"],
|
|
216
|
+
failed: ["bad", "failed"],
|
|
217
|
+
expired: ["bad", "expired"],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Minutes left on a live request, or null. */
|
|
221
|
+
function minutesLeft(rec) {
|
|
222
|
+
if (!rec.expiresAt) return null;
|
|
223
|
+
if (rec.status !== "pending" && rec.status !== "awaiting_phone") return null;
|
|
224
|
+
return Math.round((Number(rec.expiresAt) * 1000 - Date.now()) / 60000);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A queue row. Built from DOM nodes so agent text can never be markup. */
|
|
228
|
+
function row(rec) {
|
|
229
|
+
const wrap = document.createElement("div");
|
|
230
|
+
const live = rec.status === "pending" || rec.status === "awaiting_phone";
|
|
231
|
+
wrap.className = "item" + (live ? " live" : "") +
|
|
232
|
+
(rec.status === "settled" || rec.status === "denied" ? " hist" : "");
|
|
138
233
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
234
|
+
const top = document.createElement("div");
|
|
235
|
+
top.className = "top";
|
|
236
|
+
const amt = document.createElement("div");
|
|
237
|
+
amt.className = "amt";
|
|
238
|
+
amt.textContent = `${rec.price} ${rec.token || ""}`;
|
|
239
|
+
const [, cls] = STATUS_TAG[rec.status] || ["warn", rec.status];
|
|
240
|
+
const tag = document.createElement("span");
|
|
241
|
+
tag.className = "tag " + cls;
|
|
242
|
+
tag.textContent = (STATUS_TAG[rec.status] || [null, rec.status])[1] || rec.status;
|
|
243
|
+
top.append(amt, tag);
|
|
244
|
+
|
|
245
|
+
const uri = document.createElement("div");
|
|
246
|
+
uri.className = "uri";
|
|
247
|
+
uri.textContent = rec.uri || "";
|
|
248
|
+
|
|
249
|
+
const wants = document.createElement("div");
|
|
250
|
+
wants.className = "wants";
|
|
251
|
+
wants.textContent = rec.wants || "";
|
|
252
|
+
|
|
253
|
+
const foot = document.createElement("div");
|
|
254
|
+
foot.className = "foot";
|
|
255
|
+
const when = document.createElement("span");
|
|
256
|
+
when.className = "ago";
|
|
257
|
+
when.textContent = ago(rec.at) + (rec.tx ? " \u00b7 " + rec.tx.slice(0, 12) + "\u2026" : "");
|
|
258
|
+
foot.append(when);
|
|
259
|
+
|
|
260
|
+
// The other way a payment dies is the clock. Show the time left on the row,
|
|
261
|
+
// so "I approved it and nothing happened" is not the first warning.
|
|
262
|
+
const left = minutesLeft(rec);
|
|
263
|
+
if (left !== null) {
|
|
264
|
+
const clock = document.createElement("span");
|
|
265
|
+
clock.className = "clock" + (left <= 5 ? " low" : "");
|
|
266
|
+
clock.textContent = left > 0 ? left + " min left" : "expired";
|
|
267
|
+
foot.append(clock);
|
|
144
268
|
}
|
|
145
269
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
270
|
+
// A failed row with no reason is a dead end: the human cannot tell a passed
|
|
271
|
+
// deadline from a wrong key, and both look like "it says failed".
|
|
272
|
+
if (rec.status === "failed" && rec.reason) {
|
|
273
|
+
const why = document.createElement("div");
|
|
274
|
+
why.className = "wants";
|
|
275
|
+
why.textContent = rec.reason;
|
|
276
|
+
wrap.append(top, uri, wants, why, foot);
|
|
277
|
+
return wrap;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (live) {
|
|
281
|
+
const btns = document.createElement("div");
|
|
282
|
+
btns.className = "btns";
|
|
283
|
+
const yes = document.createElement("button");
|
|
284
|
+
yes.textContent = "Review";
|
|
285
|
+
yes.addEventListener("click", () => openReview(rec.id));
|
|
286
|
+
const no = document.createElement("button");
|
|
287
|
+
no.className = "danger";
|
|
288
|
+
no.textContent = "Reject";
|
|
289
|
+
no.addEventListener("click", () => reject(rec.id));
|
|
290
|
+
btns.append(yes, no);
|
|
291
|
+
foot.append(btns);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
wrap.append(top, uri, wants, foot);
|
|
295
|
+
return wrap;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The home-screen icon badge: the count of what is waiting. On an installed PWA
|
|
299
|
+
// this is the only way a person learns money is waiting without opening the app.
|
|
300
|
+
async function badge(n) {
|
|
301
|
+
try {
|
|
302
|
+
if (n > 0 && navigator.setAppBadge) await navigator.setAppBadge(n);
|
|
303
|
+
else if (navigator.clearAppBadge) await navigator.clearAppBadge();
|
|
304
|
+
} catch { /* badging is not granted, or not supported */ }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let queueTimer = null;
|
|
308
|
+
let lastLive = 0;
|
|
309
|
+
async function refreshQueue() {
|
|
310
|
+
if (!key()) return;
|
|
311
|
+
try {
|
|
312
|
+
const list = await api("/queue");
|
|
313
|
+
const live = list.filter((r) => r.status === "pending" || r.status === "awaiting_phone");
|
|
314
|
+
const past = list.filter((r) => r.status !== "pending" && r.status !== "awaiting_phone").slice(0, 5);
|
|
315
|
+
const box = $("qlist");
|
|
316
|
+
box.replaceChildren();
|
|
317
|
+
if (!live.length && !past.length) {
|
|
318
|
+
const e = document.createElement("div");
|
|
319
|
+
e.className = "empty";
|
|
320
|
+
e.textContent = "nothing waiting";
|
|
321
|
+
box.append(e);
|
|
154
322
|
}
|
|
155
|
-
|
|
323
|
+
live.forEach((r) => box.append(row(r)));
|
|
324
|
+
past.forEach((r) => box.append(row(r)));
|
|
325
|
+
// The hand-paste box is the fallback for a seller who sends a transaction by
|
|
326
|
+
// message instead of a link. It appears when there is nothing in the queue,
|
|
327
|
+
// so a fresh device never meets "paste the transaction" before its payments.
|
|
328
|
+
show("paste", !live.length);
|
|
329
|
+
set("qwhen", new Date().toLocaleTimeString());
|
|
330
|
+
set("qtitle", live.length ? `${live.length} waiting for you` : "Waiting for you");
|
|
331
|
+
err("qerr", "");
|
|
332
|
+
show("offline", false);
|
|
333
|
+
// buzz once when a new request arrives while the app is open
|
|
334
|
+
if (live.length > lastLive) buzz(30);
|
|
335
|
+
lastLive = live.length;
|
|
336
|
+
badge(live.length);
|
|
337
|
+
// Hand the count to the native shell: it owns the icon badge and the local
|
|
338
|
+
// notification, which a web page cannot raise on iOS. It gets a number and
|
|
339
|
+
// nothing else — no amount, no payee — because a lock-screen payment prompt
|
|
340
|
+
// is a payment decision made on a screen nobody unlocked deliberately.
|
|
341
|
+
try { globalThis.webkit?.messageHandlers?.queue?.postMessage({ waiting: live.length }); } catch { /* not native */ }
|
|
342
|
+
} catch (e) {
|
|
343
|
+
// Distinguish "the desk is down" from "this link was never issued": the
|
|
344
|
+
// first is a network problem the seller fixes, the second is a key problem.
|
|
345
|
+
err("qerr", e.message + " — if this is new, open the seller's approval link again");
|
|
346
|
+
if (!navigator.onLine) show("offline", true);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ----------------------------------------------------------------- review
|
|
351
|
+
let current = null; // { id, xdr, hash, summary }
|
|
156
352
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
353
|
+
async function openReview(id) {
|
|
354
|
+
err("qerr", "");
|
|
355
|
+
try {
|
|
356
|
+
const rec = await api("/challenge/" + encodeURIComponent(id));
|
|
357
|
+
const env = X.parseEnvelope(rec.xdr);
|
|
358
|
+
const op = env.tx.ops[0];
|
|
359
|
+
if (env.tx.ops.length !== 1 || op.type !== X.OP_PAYMENT) {
|
|
360
|
+
throw new Error("this transaction is not a single payment \u2014 refusing to sign it");
|
|
361
|
+
}
|
|
362
|
+
const { hash } = await X.transactionHash(rec.xdr, X.NETWORK_ID[rec.network] || rec.network);
|
|
363
|
+
if (rec.hash && hash !== rec.hash) {
|
|
364
|
+
throw new Error("the transaction does not hash to what the desk advertised \u2014 refusing");
|
|
365
|
+
}
|
|
366
|
+
// The vault only accepts the key it was built with. A seed from an earlier
|
|
367
|
+
// run produces a perfectly valid signature over a perfectly valid
|
|
368
|
+
// transaction, and the ledger's only reply is BAD_AUTH \u2014 which reads like a
|
|
369
|
+
// bug and is really the wrong key. Say it before signing instead.
|
|
370
|
+
if (rec.approver && approverPublic && rec.approver !== approverPublic) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
"this vault expects a different approver key.\n" +
|
|
373
|
+
"vault wants: " + rec.approver.slice(0, 12) + "\u2026\n" +
|
|
374
|
+
"this device: " + approverPublic.slice(0, 12) + "\u2026\n" +
|
|
375
|
+
"The seller re-ran the desk, which made a new key. Get the new seed.");
|
|
376
|
+
}
|
|
377
|
+
current = {
|
|
378
|
+
id, xdr: rec.xdr, hash,
|
|
379
|
+
amount: X.formatAmount(op.amount),
|
|
380
|
+
token: op.asset.code || "XLM",
|
|
381
|
+
issuer: op.asset.issuer || "",
|
|
382
|
+
payee: X.encodeAccountEd25519(op.destination.pub),
|
|
383
|
+
vault: X.encodeAccountEd25519(env.tx.source.pub),
|
|
384
|
+
// Read the memo out of the envelope, not out of the server's summary:
|
|
385
|
+
// the envelope is what is being signed, so it is the only source that
|
|
386
|
+
// cannot disagree with the bytes. xdr.js already renders every memo type
|
|
387
|
+
// to a plain string, so this is display-safe by construction.
|
|
388
|
+
memo: env.tx.memo || "(none)",
|
|
389
|
+
deadline: rec.expiresAt || env.tx.cond?.timeBounds?.max || "",
|
|
390
|
+
network: rec.network,
|
|
391
|
+
wants: rec.wants || "",
|
|
392
|
+
};
|
|
393
|
+
// The amount is the one number a person must read. It gets the biggest type
|
|
394
|
+
// on the screen and the token sits next to it, so "4.50" is never read
|
|
395
|
+
// without its unit.
|
|
396
|
+
$("amount").replaceChildren();
|
|
397
|
+
const num = document.createElement("span");
|
|
398
|
+
num.textContent = current.amount;
|
|
399
|
+
const tk = document.createElement("span");
|
|
400
|
+
tk.className = "tk";
|
|
401
|
+
tk.textContent = " " + current.token;
|
|
402
|
+
$("amount").append(num, tk);
|
|
403
|
+
set("wantsBig", current.wants);
|
|
404
|
+
set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
|
|
405
|
+
set("payee", current.payee);
|
|
406
|
+
set("vault", current.vault);
|
|
407
|
+
set("wants", current.wants);
|
|
408
|
+
set("memo", current.memo);
|
|
409
|
+
set("deadline", current.deadline ? new Date(Number(current.deadline) * 1000).toLocaleString() : "none");
|
|
410
|
+
set("network", current.network === "public" ? "Stellar mainnet" : current.network);
|
|
411
|
+
set("hash", current.hash);
|
|
412
|
+
// The network belongs in the header, not buried in the list: signing a
|
|
413
|
+
// testnet transaction and signing real money look identical otherwise, and
|
|
414
|
+
// the chip is the one place a person glances before holding the button.
|
|
415
|
+
set("netChip", current.network === "public" ? "mainnet" : current.network);
|
|
416
|
+
show("netChip", true);
|
|
417
|
+
$("netChip").style.color = current.network === "public" ? "var(--ok)" : "var(--warn)";
|
|
418
|
+
err("signErr", "");
|
|
419
|
+
resetHold();
|
|
420
|
+
show("review", true);
|
|
421
|
+
show("queue", false);
|
|
422
|
+
$("review").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
423
|
+
} catch (e) {
|
|
424
|
+
err("qerr", e.message);
|
|
164
425
|
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function backToQueue() {
|
|
429
|
+
current = null;
|
|
430
|
+
show("review", false);
|
|
431
|
+
show("result", false);
|
|
432
|
+
show("queue", !!key());
|
|
433
|
+
refreshQueue();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ------------------------------------------------------ hold-to-approve
|
|
437
|
+
// Approving money is the expensive action; rejecting is the safe one. So Reject
|
|
438
|
+
// is one tap and Approve needs a deliberate hold. This is what stops a pocket
|
|
439
|
+
// tap, a mis-tap, and the person who taps Approve and only then starts reading.
|
|
440
|
+
const HOLD_MS = 1200;
|
|
441
|
+
let holdStart = 0, holdTimer = null;
|
|
165
442
|
|
|
166
|
-
|
|
167
|
-
$("
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
443
|
+
function resetHold() {
|
|
444
|
+
$("holdFill").style.width = "0";
|
|
445
|
+
$("holdLbl").textContent = "Hold to approve & sign";
|
|
446
|
+
$("approve").classList.remove("armed");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function armHold() {
|
|
450
|
+
holdStart = Date.now();
|
|
451
|
+
$("holdLbl").textContent = "Keep holding\u2026";
|
|
452
|
+
holdTimer = setInterval(() => {
|
|
453
|
+
const p = Math.min(1, (Date.now() - holdStart) / HOLD_MS);
|
|
454
|
+
$("holdFill").style.width = (p * 100) + "%";
|
|
455
|
+
if (p >= 1) {
|
|
456
|
+
clearInterval(holdTimer); holdTimer = null;
|
|
457
|
+
$("approve").classList.add("armed");
|
|
458
|
+
$("holdLbl").textContent = "Signed";
|
|
459
|
+
buzz([20, 40, 20]);
|
|
460
|
+
signAndSubmit();
|
|
172
461
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
462
|
+
}, 40);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function cancelHold() {
|
|
466
|
+
if (holdTimer) { clearInterval(holdTimer); holdTimer = null; }
|
|
467
|
+
if (!holdStart) return;
|
|
468
|
+
holdStart = 0;
|
|
469
|
+
if (!$("approve").classList.contains("armed")) resetHold();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function wireHold() {
|
|
473
|
+
const b = $("approve");
|
|
474
|
+
b.addEventListener("pointerdown", (e) => { e.preventDefault(); if (!holdTimer) armHold(); });
|
|
475
|
+
b.addEventListener("pointerup", cancelHold);
|
|
476
|
+
b.addEventListener("pointercancel", cancelHold);
|
|
477
|
+
b.addEventListener("pointerleave", cancelHold);
|
|
478
|
+
// keyboard / VoiceOver: hold is a pointer gesture, so Enter needs a confirm
|
|
479
|
+
b.addEventListener("keydown", (e) => {
|
|
480
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
if (!confirm("Approve and sign this payment?")) return;
|
|
483
|
+
$("holdFill").style.width = "100%";
|
|
484
|
+
signAndSubmit();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ------------------------------------------------------------------ sign
|
|
490
|
+
async function signAndSubmit() {
|
|
491
|
+
if (!current) return;
|
|
492
|
+
if (!approverKey && !native.available()) { err("signErr", "no approver key on this device"); return; }
|
|
493
|
+
const button = $("approve");
|
|
494
|
+
button.disabled = true;
|
|
495
|
+
try {
|
|
496
|
+
// Re-hash at the moment of signing. What is signed is what was on screen.
|
|
497
|
+
const { hash } = await X.transactionHash(current.xdr, X.NETWORK_ID[current.network] || current.network);
|
|
498
|
+
if (hash !== current.hash) throw new Error("the transaction changed under you \u2014 nothing was signed");
|
|
499
|
+
// One signer for both paths. The preimage is built here, from the hash this
|
|
500
|
+
// file computed, so the native side is asked to sign exactly what was on
|
|
501
|
+
// screen and is never handed a transaction to interpret.
|
|
502
|
+
const pre = X.signaturePreimage(hash);
|
|
503
|
+
const sig = new Uint8Array(await signPreimage(X.hex(pre)));
|
|
504
|
+
if (!approverPubBytes) throw new Error("this device lost the approver's public key — re-import it");
|
|
505
|
+
// Prove the signature came from the key this screen named, before a byte
|
|
506
|
+
// of it leaves the device. A bridge that signs with some other key is then
|
|
507
|
+
// useless: its output never reaches the ledger.
|
|
508
|
+
if (!await globalThis.PaydluxVerify.verifySignature(sig, hash, approverPubBytes)) {
|
|
509
|
+
throw new Error("the signature does not match this key — nothing was sent");
|
|
191
510
|
}
|
|
511
|
+
const signed = X.addSignature(current.xdr, sig, approverPubBytes);
|
|
512
|
+
const out = await api("/signed/" + encodeURIComponent(current.id), {
|
|
513
|
+
method: "POST",
|
|
514
|
+
headers: { "content-type": "application/json" },
|
|
515
|
+
body: JSON.stringify({ signedXdr: signed }),
|
|
516
|
+
});
|
|
517
|
+
set("signed", signed);
|
|
518
|
+
const tag = $("resultTag");
|
|
519
|
+
tag.className = "tag " + (out.status === "settled" ? "ok" : "bad");
|
|
520
|
+
tag.textContent = out.status === "settled" ? "Paid" : out.status;
|
|
521
|
+
if (out.tx) set("resultLabel", `Signed \u00b7 ledger tx ${out.tx}`);
|
|
522
|
+
if (out.reason) set("resultLabel", out.reason);
|
|
523
|
+
show("result", true);
|
|
524
|
+
show("review", false);
|
|
525
|
+
buzz(out.status === "settled" ? [20, 40, 20] : 200);
|
|
526
|
+
badge(0);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
err("signErr", e.message);
|
|
529
|
+
resetHold();
|
|
530
|
+
buzz(200);
|
|
531
|
+
} finally {
|
|
532
|
+
button.disabled = false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
192
535
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
$
|
|
226
|
-
$(
|
|
227
|
-
|
|
228
|
-
|
|
536
|
+
async function reject(id) {
|
|
537
|
+
try {
|
|
538
|
+
await api("/deny/" + encodeURIComponent(id), { method: "POST" });
|
|
539
|
+
buzz(60);
|
|
540
|
+
refreshQueue();
|
|
541
|
+
} catch (e) {
|
|
542
|
+
err("qerr", e.message);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ------------------------------------------------------- hand-pasted xdr
|
|
547
|
+
// For a seller who sends the pending transaction by hand. There is no trusted
|
|
548
|
+
// source for it, so the app makes the human type back the hash the seller
|
|
549
|
+
// quoted: signing then proves the human saw the same bytes, not a swapped one.
|
|
550
|
+
async function readPasted() {
|
|
551
|
+
err("readErr", "");
|
|
552
|
+
try {
|
|
553
|
+
if (!approverKey || !approverPubBytes) throw new Error("import the approver key first");
|
|
554
|
+
const xdr = $("xdr").value.trim();
|
|
555
|
+
if (!xdr) throw new Error("paste the transaction first");
|
|
556
|
+
const env = X.parseEnvelope(xdr);
|
|
557
|
+
if (env.tx.ops.length !== 1 || env.tx.ops[0].type !== X.OP_PAYMENT) {
|
|
558
|
+
throw new Error("not a single payment \u2014 refusing");
|
|
559
|
+
}
|
|
560
|
+
const op = env.tx.ops[0];
|
|
561
|
+
const local = location.hostname === "localhost" || /^127\./.test(location.hostname);
|
|
562
|
+
const net = local ? "testnet" : "public";
|
|
563
|
+
const { hash } = await X.transactionHash(xdr, X.NETWORK_ID[net]);
|
|
564
|
+
// No trusted source, so the human proves they saw these exact bytes by
|
|
565
|
+
// typing back the hash the seller quoted. A swapped transaction hashes
|
|
566
|
+
// differently and this check fails.
|
|
567
|
+
const want = prompt(
|
|
568
|
+
`Network: ${net}\n` +
|
|
569
|
+
`This pays ${X.formatAmount(op.amount)} ${op.asset.code || "XLM"} to ` +
|
|
570
|
+
`${X.encodeAccountEd25519(op.destination.pub)}.\n\n` +
|
|
571
|
+
`Type the first 12 characters of the hash the seller quoted to confirm you are ` +
|
|
572
|
+
`signing the same transaction.`);
|
|
573
|
+
if (!want) return;
|
|
574
|
+
if (want.trim().toLowerCase() !== hash.slice(0, 12)) {
|
|
575
|
+
throw new Error("that is not the hash the seller quoted \u2014 nothing was signed");
|
|
229
576
|
}
|
|
577
|
+
const sig = new Uint8Array(await signPreimage(X.hex(X.signaturePreimage(hash))));
|
|
578
|
+
if (!await globalThis.PaydluxVerify.verifySignature(sig, hash, approverPubBytes)) {
|
|
579
|
+
throw new Error("the signature does not match this key — nothing was produced");
|
|
580
|
+
}
|
|
581
|
+
set("signed", X.addSignature(xdr, sig, approverPubBytes));
|
|
582
|
+
set("resultLabel", "Signed \u2014 send this back to the seller");
|
|
583
|
+
$("resultTag").className = "tag ok";
|
|
584
|
+
$("resultTag").textContent = "Signed";
|
|
585
|
+
show("result", true);
|
|
586
|
+
} catch (e) {
|
|
587
|
+
err("readErr", e.message);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ------------------------------------------------------------------ boot
|
|
592
|
+
async function boot() {
|
|
593
|
+
// A deep link from the seller's desk saves the approval link automatically.
|
|
594
|
+
const here = new URL(location.href);
|
|
595
|
+
if (here.searchParams.get("k")) {
|
|
596
|
+
const u = new URL(location.pathname, location.origin);
|
|
597
|
+
const p = new URLSearchParams();
|
|
598
|
+
for (const [k2, v] of here.searchParams) p.set(k2, v);
|
|
599
|
+
localStorage.setItem(LINK, u.pathname + "?" + p.toString());
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
await loadKey();
|
|
603
|
+
paintKeyState();
|
|
604
|
+
paintLinkState();
|
|
605
|
+
|
|
606
|
+
// iOS gives an installed PWA no browser chrome, and Safari offers no install
|
|
607
|
+
// button, so the instruction has to live inside the app. Shown once per device.
|
|
608
|
+
const standalone = matchMedia("(display-mode: standalone)").matches
|
|
609
|
+
|| navigator.standalone === true;
|
|
610
|
+
show("install", !standalone && !localStorage.getItem("paydlux.installDismissed"));
|
|
611
|
+
$("install").addEventListener("click", () => {
|
|
612
|
+
localStorage.setItem("paydlux.installDismissed", "1");
|
|
613
|
+
show("install", false);
|
|
230
614
|
});
|
|
231
615
|
|
|
232
|
-
|
|
616
|
+
// The offline shell: the app launches from cache with its icon and code even
|
|
617
|
+
// with no network, then fails honestly when it tries to read the queue. It
|
|
618
|
+
// never caches payment data — see sw.js.
|
|
619
|
+
if ("serviceWorker" in navigator) {
|
|
620
|
+
navigator.serviceWorker.register("sw.js", { scope: "./" }).catch(() => { /* not fatal */ });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
$("enrolBtn").addEventListener("click", async () => {
|
|
624
|
+
err("enrolErr", "");
|
|
233
625
|
try {
|
|
234
|
-
await
|
|
235
|
-
|
|
626
|
+
await enrol($("seed").value);
|
|
627
|
+
paintKeyState();
|
|
628
|
+
refreshQueue();
|
|
236
629
|
} catch (e) {
|
|
237
|
-
|
|
630
|
+
err("enrolErr", e.message);
|
|
238
631
|
}
|
|
239
632
|
});
|
|
633
|
+
$("seed").addEventListener("keydown", (e) => { if (e.key === "Enter") $("enrolBtn").click(); });
|
|
634
|
+
$("forget").addEventListener("click", async () => {
|
|
635
|
+
await idbDel();
|
|
636
|
+
localStorage.removeItem("paydlux.pub");
|
|
637
|
+
approverKey = null; approverPublic = null;
|
|
638
|
+
paintKeyState();
|
|
639
|
+
});
|
|
640
|
+
$("linkBtn").addEventListener("click", () => {
|
|
641
|
+
const raw = $("link").value.trim();
|
|
642
|
+
if (!raw) return;
|
|
643
|
+
const u = new URL(raw, location.origin);
|
|
644
|
+
if (!u.searchParams.get("k")) { err("linkErr", "that link has no approval key in it"); return; }
|
|
645
|
+
localStorage.setItem(LINK, u.pathname + "?" + u.searchParams.toString());
|
|
646
|
+
err("linkErr", "");
|
|
647
|
+
paintLinkState();
|
|
648
|
+
refreshQueue();
|
|
649
|
+
});
|
|
650
|
+
wireHold();
|
|
651
|
+
$("reject").addEventListener("click", () => current && reject(current.id) && backToQueue());
|
|
652
|
+
$("again").addEventListener("click", backToQueue);
|
|
653
|
+
$("read").addEventListener("click", readPasted);
|
|
654
|
+
$("copy").addEventListener("click", async () => {
|
|
655
|
+
try { await navigator.clipboard.writeText($("signed").textContent); $("copy").textContent = "Copied"; }
|
|
656
|
+
catch { $("copy").textContent = "Copy failed \u2014 select the text"; }
|
|
657
|
+
setTimeout(() => { $("copy").textContent = "Copy"; }, 1500);
|
|
658
|
+
});
|
|
240
659
|
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
660
|
+
// Poll only while visible. An approver app left open on a phone should not
|
|
661
|
+
// wake the radio every few seconds in a pocket.
|
|
662
|
+
const startPolling = () => {
|
|
663
|
+
if (queueTimer) return;
|
|
664
|
+
queueTimer = setInterval(refreshQueue, 4000);
|
|
665
|
+
refreshQueue();
|
|
666
|
+
};
|
|
667
|
+
const stopPolling = () => { if (queueTimer) { clearInterval(queueTimer); queueTimer = null; } };
|
|
668
|
+
document.addEventListener("visibilitychange", () => {
|
|
669
|
+
if (document.hidden) stopPolling(); else if (key()) startPolling();
|
|
670
|
+
});
|
|
671
|
+
window.addEventListener("online", () => { show("offline", false); refreshQueue(); });
|
|
672
|
+
window.addEventListener("offline", () => show("offline", true));
|
|
673
|
+
|
|
674
|
+
if (key()) {
|
|
675
|
+
await refreshQueue();
|
|
676
|
+
if (!document.hidden) startPolling();
|
|
677
|
+
const id = new URLSearchParams(localStorage.getItem(LINK)?.split("?")[1] || "").get("id");
|
|
678
|
+
if (id) openReview(id);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
boot().catch((e) => { err("qerr", e.message); });
|