@paydlux/agent 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,8 @@ stellar/agent.ts the buyer side — one class, one method call
33
33
  stellar/desk.ts approval queue: a human decides, in the clear
34
34
  stellar/portal.ts the page that human looks at
35
35
  stellar/policy.ts daily budget / seller allowlist (the part a ledger can't count)
36
- stellar/demo.ts 12 checks against the live testnet, one command
36
+ stellar/demo.ts 17 checks against the live testnet, one command
37
+ phone-app/ the approver app a human opens on their own device
37
38
  stellar/probe-*.ts the experiments that settled how the ledger behaves
38
39
  stellar/onboard.ts keys → funded vault → printed agent key
39
40
  ```
@@ -42,8 +43,8 @@ stellar/onboard.ts keys → funded vault → printed agent key
42
43
 
43
44
  ```bash
44
45
  npm i
45
- npx tsx stellar/demo.ts # 12 checks, live testnet
46
- npx tsx stellar/story.ts --public # one unguessable link; approve from your phone
46
+ npx tsx stellar/demo.ts # 17 checks, live testnet
47
+ npx tsx stellar/story.ts --public --split # unguessable link; approve on your phone
47
48
  ```
48
49
 
49
50
  No wallet, no faucet, no config.
@@ -54,17 +55,28 @@ The vault needs two signatures. If both keys sit in one process, that is really
54
55
  one key. So the approval half is split off:
55
56
 
56
57
  ```bash
57
- npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
58
- npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
58
+ npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
59
+ npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
60
+ npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
61
+ npx tsx phone-app/desk-app-check.ts # 28 checks: the real app over real HTTP, end to end
59
62
  ```
60
63
 
61
- The seller builds the payment and signs it with their own half. The human's
62
- device (`tools/phone-sign.ts`) decodes it, shows a person the amount and payee,
63
- and signs offline. The approver seed never travels to the seller's machine, and
64
- the signature is over the transaction hash — change the amount or the payee and
65
- it stops counting.
66
-
67
- No wallet, no faucet, no config.
64
+ The seller builds the payment and signs it with their own half. The human opens
65
+ one link — `/phone/?id=...` — and the approver app in `phone-app/` shows them the
66
+ amount, the token, the payee and the deadline, then signs on the device.
67
+
68
+ The app is three static files with no dependencies and no build step. It carries
69
+ its own Stellar transaction decoder (`phone-app/xdr.js`) rather than the SDK, and
70
+ refuses any transaction shape it does not fully understand: path payments,
71
+ account merges, signer changes and multi-operation transactions are rejected
72
+ outright rather than guessed at. The key is imported into WebCrypto as
73
+ **non-extractable** and only the `CryptoKey` object itself is persisted, so the
74
+ seed is never in localStorage, IndexedDB, or a file. The page is served with
75
+ `connect-src 'self'`, so an injected script cannot phone the key home.
76
+
77
+ The approver seed never travels to the seller's machine, and the signature is
78
+ over the transaction hash — change the amount or the payee and it stops counting.
79
+ `tools/phone-sign.ts` is the same flow for a terminal instead of a browser.
68
80
 
69
81
  Use it as a package instead of cloning:
70
82
 
package/dist/desk.js CHANGED
@@ -10,12 +10,38 @@
10
10
  // - a stolen phone cannot approve alone
11
11
  // - the approval is a real ledger signature, not a database flag
12
12
  import { createServer } from "node:http";
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
13
16
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
17
  import { Keypair } from "@stellar/stellar-sdk";
15
18
  import { TransactionBuilder } from "@stellar/stellar-sdk";
16
19
  import { passphrase } from "./vault.js";
17
20
  import { portalPage } from "./portal.js";
18
21
  import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
22
+ /**
23
+ * The approver app is plain static files under phone-app/. Read on each request
24
+ * rather than bundled, so the desk and the app can never drift apart.
25
+ */
26
+ const APP_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "phone-app");
27
+ const appCache = new Map();
28
+ function readAppFile(name) {
29
+ const safe = name.replace(/[^a-z.]/g, "");
30
+ const hit = appCache.get(safe);
31
+ if (hit)
32
+ return hit;
33
+ let text;
34
+ try {
35
+ text = readFileSync(join(APP_DIR, safe), "utf8");
36
+ }
37
+ catch {
38
+ // A package install that shipped without phone-app/ should say so instead of
39
+ // throwing out of the request handler.
40
+ throw new Error("approver app is missing from this install (expected " + join(APP_DIR, safe) + ")");
41
+ }
42
+ appCache.set(safe, text);
43
+ return text;
44
+ }
19
45
  /** A portal link is a capability: whoever holds it can approve. So it is long,
20
46
  * random, and compared without leaking timing. */
21
47
  export function newPortalKey() { return randomBytes(16).toString("base64url"); }
@@ -227,6 +253,66 @@ export class ApprovalDesk {
227
253
  }
228
254
  if (url.pathname === "/list")
229
255
  return send(200, this.list());
256
+ // The approver app. Served from the desk so the human opens one link on
257
+ // their phone instead of pasting XDR around. The app itself makes no
258
+ // requests after loading, and the portalKey gate above already covers
259
+ // these paths: without the key, nothing here is reachable.
260
+ // The app uses relative script srcs, so it must be served from a
261
+ // directory URL. /phone without the slash would resolve app.js to
262
+ // /app.js, so send it to the canonical path with the key intact.
263
+ if (url.pathname === "/phone") {
264
+ res.writeHead(302, { location: "/phone/" + url.search });
265
+ res.end();
266
+ return;
267
+ }
268
+ if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
269
+ res.writeHead(200, {
270
+ "content-type": "text/html; charset=utf-8",
271
+ // The key lives in IndexedDB behind a non-extractable CryptoKey;
272
+ // an injected script is the realistic threat, so lock the origin.
273
+ // No inline script on the page: app.js and xdr.js are separate
274
+ // files, so script-src 'self' actually holds. connect-src 'self' is
275
+ // what stops the app talking anywhere but the desk that served it.
276
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
277
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
278
+ "form-action 'none'; frame-ancestors 'none'",
279
+ "referrer-policy": "no-referrer",
280
+ "x-content-type-options": "nosniff",
281
+ });
282
+ res.end(readAppFile("index.html"));
283
+ return;
284
+ }
285
+ if (url.pathname.startsWith("/phone/")) {
286
+ const file = url.pathname.slice("/phone/".length);
287
+ if (!/^[a-z]+\.(js|css)$/.test(file))
288
+ return send(404, { error: "not found" });
289
+ res.writeHead(200, {
290
+ "content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
291
+ "cache-control": "no-store",
292
+ "x-content-type-options": "nosniff",
293
+ });
294
+ res.end(readAppFile(file));
295
+ return;
296
+ }
297
+ // The app fetches the pending transaction by handle rather than asking
298
+ // the human to paste 400 characters of base64 on a phone.
299
+ const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
300
+ if (ch) {
301
+ const rec = this.records.get(ch[1]);
302
+ if (!rec)
303
+ return send(404, { error: "unknown handle" });
304
+ // Opening the phone link IS the approval gesture, so build the
305
+ // challenge here if nobody pressed the button yet. The human should
306
+ // need one link, not a button on a desktop plus a link on a phone.
307
+ if (rec.status === "pending")
308
+ await this.approve(rec.id);
309
+ if (!rec.challenge)
310
+ return send(409, { error: `not awaiting a phone signature (it is ${rec.status})` });
311
+ return send(200, {
312
+ id: rec.id, network: rec.challenge.network,
313
+ hash: rec.challenge.hash, xdr: rec.challenge.xdr,
314
+ });
315
+ }
230
316
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
231
317
  if (m && req.method === "POST") {
232
318
  if (m[1] === "signed") {
package/dist/gateway.d.ts CHANGED
@@ -19,6 +19,14 @@ export type GatewayConfig = {
19
19
  /** human keys. In production these live on the phone, never here. */
20
20
  principal?: string;
21
21
  approver?: string;
22
+ /**
23
+ * Split mode: the desk gets the principal key (the seller's own half) but NOT
24
+ * the approver key, so this process cannot approve anything. Approval happens
25
+ * on a second device through the approver app. Pass `approver: undefined`
26
+ * together with this; the gateway still settles, using the signature the
27
+ * phone sends back.
28
+ */
29
+ split?: boolean;
22
30
  deskPort?: number;
23
31
  /** gate the approval portal behind a single unguessable link */
24
32
  portalKey?: string;
package/dist/gateway.js CHANGED
@@ -35,6 +35,7 @@ export class Gateway {
35
35
  approver: cfg.approver,
36
36
  port: cfg.deskPort ?? 4022,
37
37
  portalKey: cfg.portalKey,
38
+ split: cfg.split,
38
39
  });
39
40
  }
40
41
  /** Mandates the vault currently holds, so the gateway can hand them out. */
package/dist/portal.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export function portalPage(records, vault, network, qs = "") {
2
2
  const rows = records.length
3
- ? records.map(render).join("\n")
3
+ ? records.map((r) => render(r, qs)).join("\n")
4
4
  : `<tr><td colspan="5" class="empty">nothing waiting</td></tr>`;
5
5
  return `<!doctype html><html><head>
6
6
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -25,6 +25,7 @@ export function portalPage(records, vault, network, qs = "") {
25
25
  .tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
26
26
  .qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
27
27
  details summary{cursor:pointer}
28
+ .phlink{color:var(--gold);text-decoration:underline;font-size:12px}
28
29
  .foot{margin-top:28px;color:var(--muted);font-size:12px}
29
30
  @media(max-width:640px){.body{max-width:180px}}
30
31
  </style></head><body>
@@ -46,15 +47,22 @@ export function portalPage(records, vault, network, qs = "") {
46
47
  setInterval(load,3000);
47
48
  </script></body></html>`;
48
49
  }
49
- function render(r) {
50
+ function render(r, qs = "") {
51
+ // qs is the portal key query string ("?k=..."). The approver link has to carry
52
+ // it, because the desk answers 403 for anything without it.
53
+ const link = `phone/?id=${encodeURIComponent(r.id)}${qs.replace("?", "&")}`;
50
54
  const btns = r.status === "pending"
51
55
  ? `<button class="yes" onclick="act('approve','${r.id}')">Approve</button>
52
56
  <button class="no" onclick="act('deny','${r.id}')">Deny</button>`
53
57
  : "";
54
58
  // Split mode: the desk has no approver key, so approving hands the payment to
55
59
  // a phone. Show the human exactly what the phone will be asked to sign.
60
+ // Split mode: the desk has no approver key, so approving hands the payment to
61
+ // a phone. Show the human exactly what the phone will be asked to sign, and
62
+ // give the approver app a link so nobody pastes base64 on a small screen.
56
63
  const phone = r.status === "awaiting_phone" && r.challenge
57
64
  ? `<div class="tx">awaiting your phone · ${r.challenge.summary.amount} ${r.challenge.summary.token} to ${r.challenge.summary.payee.slice(0, 12)}…</div>
65
+ <div class="tx"><a class="phlink" href="${link}">open on your phone to sign</a></div>
58
66
  <details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${r.qr}</pre></details>`
59
67
  : "";
60
68
  const meta = [phone, r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",
@@ -13,6 +13,9 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "phone-app/index.html",
17
+ "phone-app/app.js",
18
+ "phone-app/xdr.js",
16
19
  "README.md",
17
20
  "LICENSE"
18
21
  ],
@@ -28,6 +31,7 @@
28
31
  "story:stellar": "tsx stellar/story.ts",
29
32
  "test": "cd contracts && forge test",
30
33
  "test:stellar": "tsx stellar/demo.ts",
34
+ "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts",
31
35
  "typecheck": "tsc --noEmit"
32
36
  },
33
37
  "keywords": [
@@ -0,0 +1,253 @@
1
+ // The approver app's own logic. Loaded by phone-app/index.html and served by the
2
+ // desk at /phone/app.js. Kept in a separate file so the Content-Security-Policy
3
+ // (script-src 'self') is real rather than defeated by an inline <script>.
4
+ (function () {
5
+ "use strict";
6
+ const X = window.PaydluxXdr;
7
+ const $ = (id) => document.getElementById(id);
8
+ const DB = "paydlux-approver", STORE = "keys";
9
+
10
+ let signingKey = null;
11
+
12
+ function idb(mode, fn) {
13
+ return new Promise((res, rej) => {
14
+ const open = indexedDB.open(DB, 1);
15
+ open.onupgradeneeded = () => open.result.createObjectStore(STORE);
16
+ open.onerror = () => rej(open.error);
17
+ open.onsuccess = () => {
18
+ const tx = open.result.transaction(STORE, mode);
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
+ }
35
+
36
+ async function showReady(pub) {
37
+ $("who").textContent = X.encodeAccountEd25519(pub);
38
+ $("enrol").classList.add("hidden");
39
+ $("ready").classList.remove("hidden");
40
+ }
41
+
42
+ // ------------------------------------------------------------- enrolment
43
+ $("enrolBtn").addEventListener("click", async () => {
44
+ $("enrolErr").textContent = "";
45
+ const seed = $("seed").value.trim();
46
+ try {
47
+ // Imported twice on purpose: once extractable purely to read the PUBLIC
48
+ // half back for display, once non-extractable as the key we keep.
49
+ const seedBytes = X.decodeSecretSeed(seed);
50
+ const pkcs8 = new Uint8Array(16 + 32);
51
+ pkcs8.set([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20], 0);
52
+ pkcs8.set(seedBytes, 16);
53
+
54
+ // "verify" is not a legal usage for a private Ed25519 key — the browser
55
+ // refuses the import outright. Sign only.
56
+ const tmp = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
57
+ const jwk = await crypto.subtle.exportKey("jwk", tmp);
58
+ const pub = base64urlToBytes(jwk.x);
59
+
60
+ signingKey = await X.importSecretSeed(seed); // non-extractable
61
+ seedBytes.fill(0); pkcs8.fill(0); $("seed").value = "";
62
+
63
+ // What is persisted is the non-extractable CryptoKey object, not the seed.
64
+ // Whatever backs IndexedDB can use that key to sign; no code path can read
65
+ // the seed out of it. Losing the database leaks nothing; stealing it leaks
66
+ // nothing either.
67
+ await saveKey({ key: signingKey, x: jwk.x });
68
+ await showReady(pub);
69
+ } catch (e) {
70
+ $("enrolErr").textContent = e.message;
71
+ }
72
+ });
73
+
74
+ $("forget").addEventListener("click", async () => {
75
+ signingKey = null;
76
+ await dropKey();
77
+ location.reload();
78
+ });
79
+
80
+ // --------------------------------------------------------------- review
81
+ let pending = null;
82
+
83
+ /**
84
+ * Deep link from the desk: /phone/?id=pay_0001&k=PORTALKEY
85
+ * The human taps a link instead of pasting 400 characters of base64 on a
86
+ * phone. The desk returns its own record of the challenge, so the amount the
87
+ * phone displays is the amount the desk recorded.
88
+ */
89
+ async function loadFromDesk() {
90
+ const q = new URLSearchParams(location.search);
91
+ const id = q.get("id"), k = q.get("k");
92
+ if (!id) return;
93
+ try {
94
+ const res = await fetch("/challenge/" + encodeURIComponent(id) +
95
+ (k ? "?k=" + encodeURIComponent(k) : ""), { cache: "no-store" });
96
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.status);
97
+ const rec = await res.json();
98
+ $("xdr").value = rec.xdr;
99
+ pending = { fromDesk: true, id, k };
100
+ await renderChallenge(rec.xdr);
101
+ $("readErr").textContent = "";
102
+ } catch (e) {
103
+ $("readErr").textContent = "could not load " + id + ": " + e.message;
104
+ }
105
+ }
106
+
107
+ async function renderChallenge(xdrText) {
108
+ const env = X.parseEnvelope(xdrText);
109
+ const op = env.tx.ops[0];
110
+ const testnet = await X.transactionHash(xdrText, X.NETWORK_ID.testnet);
111
+ const publicNet = await X.transactionHash(xdrText, X.NETWORK_ID.public);
112
+ pending = Object.assign({}, pending, {
113
+ xdrText, env, hashes: { testnet: testnet.hash, public: publicNet.hash },
114
+ });
115
+
116
+ $("amount").textContent = X.formatAmount(op.amount) + " " + op.asset.code;
117
+ $("token").textContent = op.asset.issuer
118
+ ? op.asset.code + " issued by " + op.asset.issuer
119
+ : "XLM (native)";
120
+ $("payee").textContent = X.encodeAccountEd25519(op.destination.pub);
121
+ $("vault").textContent = X.encodeAccountEd25519(env.tx.source.pub);
122
+ $("memo").textContent = env.tx.memo || "(none)";
123
+ const max = env.tx.bounds && env.tx.bounds.maxTime;
124
+ $("deadline").textContent = max && max !== "0"
125
+ ? new Date(Number(max) * 1000).toLocaleString() + " (" + remaining(Number(max)) + ")"
126
+ : "none — this transaction never expires";
127
+
128
+ // Which network an envelope belongs to is NOT inside the envelope, so the
129
+ // human picks it. Signing for the wrong network yields a signature the
130
+ // ledger refuses; it cannot silently move funds somewhere else.
131
+ $("hash").innerHTML =
132
+ '<label class="tag ok"><input type="radio" name="net" value="testnet" checked> testnet</label> '
133
+ + testnet.hash +
134
+ '<br><label class="tag warn"><input type="radio" name="net" value="public"> mainnet</label> '
135
+ + publicNet.hash;
136
+ $("review").classList.remove("hidden");
137
+ }
138
+
139
+ function remaining(unix) {
140
+ const s = unix - Math.floor(Date.now() / 1000);
141
+ if (s <= 0) return "EXPIRED";
142
+ if (s < 120) return s + "s left";
143
+ return Math.round(s / 60) + " min left";
144
+ }
145
+
146
+ $("read").addEventListener("click", async () => {
147
+ $("readErr").textContent = "";
148
+ $("review").classList.add("hidden");
149
+ const xdrText = $("xdr").value.trim().replace(/\s+/g, "");
150
+ try {
151
+ await renderChallenge(xdrText);
152
+ } catch (e) {
153
+ $("readErr").textContent = e.message;
154
+ }
155
+ });
156
+
157
+ $("reject").addEventListener("click", () => reset());
158
+ $("again").addEventListener("click", () => reset());
159
+ function reset() {
160
+ pending = null;
161
+ $("result").classList.add("hidden");
162
+ $("review").classList.add("hidden");
163
+ $("xdr").value = "";
164
+ }
165
+
166
+ // ---------------------------------------------------------------- sign
167
+ $("approve").addEventListener("click", async () => {
168
+ $("signErr").textContent = "";
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
+ }
192
+
193
+ try {
194
+ const { hash } = await X.transactionHash(pending.xdrText, network);
195
+ // The signature covers the 32 raw hash bytes. sha256-ing the hex text
196
+ // instead yields a well-formed envelope the ledger refuses with BAD_AUTH.
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");
227
+ } catch (e) {
228
+ $("signErr").textContent = e.message;
229
+ }
230
+ });
231
+
232
+ $("copy").addEventListener("click", async () => {
233
+ try {
234
+ await navigator.clipboard.writeText($("signed").textContent);
235
+ $("copy").textContent = "Copied";
236
+ } catch (e) {
237
+ $("copy").textContent = "Select the text above and copy it manually";
238
+ }
239
+ });
240
+
241
+ // On reload, pull the non-extractable CryptoKey back out of IndexedDB. It can
242
+ // sign; it cannot be exported. The seed itself is never stored anywhere.
243
+ (async () => {
244
+ try {
245
+ const rec = await loadKey();
246
+ if (rec && rec.key instanceof CryptoKey) {
247
+ signingKey = rec.key;
248
+ await showReady(base64urlToBytes(rec.x));
249
+ }
250
+ } catch (e) { /* enrolment UI stays up */ }
251
+ await loadFromDesk();
252
+ })();
253
+ })();
@@ -0,0 +1,148 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0b0f14">
7
+ <title>Paydlux Approver</title>
8
+ <!--
9
+ The approver half of a Paydlux vault.
10
+
11
+ This page holds ONE key: the approver's. It cannot spend anything by itself.
12
+ It reads a pending payment, shows a human what they are approving, and signs
13
+ the transaction hash. Signing is a purely local operation: no network call can
14
+ change what was signed.
15
+
16
+ The page talks to one origin only — the desk that served it — to fetch the
17
+ pending transaction and hand back the signature. connect-src 'self' in the
18
+ desk's Content-Security-Policy enforces that.
19
+
20
+ What the key model actually guarantees:
21
+ - the seed becomes a NON-extractable WebCrypto key; after enrolment nothing
22
+ on this page, including an injected script, can read it back;
23
+ - the signature covers the transaction hash, so a changed amount or payee
24
+ produces a different hash and a signature the ledger refuses;
25
+ - the approver key alone cannot move funds — the vault needs 2 signatures.
26
+
27
+ What it does NOT guarantee: a person who roots this device, or who approves a
28
+ transaction without reading it. The human reading the amount and payee is part
29
+ of the security model, not a UX step.
30
+
31
+ Serve it over http(s) on a device you control: npx tsx tools/phone-serve.ts
32
+ -->
33
+ <style>
34
+ :root {
35
+ --bg:#0b0f14; --panel:#121821; --line:#22303f; --ink:#e8eef6; --dim:#8fa3b8;
36
+ --ok:#3ddc97; --warn:#ffb454; --bad:#ff6b6b; --accent:#4da3ff;
37
+ }
38
+ * { box-sizing:border-box }
39
+ body {
40
+ margin:0; background:var(--bg); color:var(--ink); font:16px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;
41
+ padding:max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom));
42
+ max-width:560px; margin-inline:auto;
43
+ }
44
+ h1 { font-size:17px; margin:0 0 4px; letter-spacing:.02em }
45
+ .sub { color:var(--dim); font-size:13px; margin-bottom:18px }
46
+ .panel { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:14px; margin-bottom:14px }
47
+ label { display:block; font-size:12px; color:var(--dim); margin:10px 0 4px; text-transform:uppercase; letter-spacing:.06em }
48
+ textarea, input {
49
+ width:100%; background:#0d131a; color:var(--ink); border:1px solid var(--line);
50
+ border-radius:10px; padding:10px; font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
51
+ word-break:break-all;
52
+ }
53
+ textarea { min-height:84px; resize:vertical }
54
+ button {
55
+ width:100%; margin-top:12px; padding:14px; border:0; border-radius:12px;
56
+ background:var(--accent); color:#04121f; font-size:16px; font-weight:700; cursor:pointer;
57
+ }
58
+ button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); font-weight:600 }
59
+ button.danger { background:var(--bad); color:#2a0707 }
60
+ button:disabled { opacity:.4 }
61
+ .row { display:flex; gap:10px }
62
+ .row > * { flex:1 }
63
+ .kv { display:grid; grid-template-columns:96px 1fr; gap:6px 10px; font-size:14px }
64
+ .kv dt { color:var(--dim); font-size:12px; text-transform:uppercase; letter-spacing:.06em; padding-top:2px }
65
+ .kv dd { margin:0; font-family:ui-monospace,Menlo,monospace; word-break:break-all; font-size:13px }
66
+ .amount { font-size:34px; font-weight:800; margin:2px 0 10px; font-variant-numeric:tabular-nums }
67
+ .tag { display:inline-block; font-size:11px; padding:3px 8px; border-radius:999px; letter-spacing:.06em; text-transform:uppercase }
68
+ .tag.ok { background:rgba(61,220,151,.14); color:var(--ok) }
69
+ .tag.warn { background:rgba(255,180,84,.14); color:var(--warn) }
70
+ .tag.bad { background:rgba(255,107,107,.14); color:var(--bad) }
71
+ .err { color:var(--bad); font-size:13px; margin-top:8px; word-break:break-word }
72
+ .hint { color:var(--dim); font-size:12px; margin-top:8px }
73
+ .hidden { display:none }
74
+ .out { background:#0d131a; border:1px solid var(--line); border-radius:10px; padding:10px;
75
+ font:12px/1.4 ui-monospace,Menlo,monospace; word-break:break-all; max-height:180px; overflow:auto }
76
+ .who { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--dim); word-break:break-all }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <h1>Paydlux Approver</h1>
81
+ <div class="sub">Signs payments on this device. The key never leaves it.</div>
82
+
83
+ <!-- ---------------------------------------------------------- enrolment -->
84
+ <div class="panel" id="enrol">
85
+ <div class="tag warn">No approver key on this device</div>
86
+ <label for="seed">Approver secret seed (S&hellip;)</label>
87
+ <input id="seed" type="password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
88
+ placeholder="S">
89
+ <button id="enrolBtn">Import key</button>
90
+ <div class="hint">
91
+ The seed becomes a non-extractable signing key and is then dropped. Only that key is
92
+ kept: it can sign, nothing can read the seed back out of it. If this device is wiped,
93
+ re-enter the seed.
94
+ </div>
95
+ <div class="err" id="enrolErr"></div>
96
+ </div>
97
+
98
+ <!-- ------------------------------------------------------------ ready -->
99
+ <div class="panel hidden" id="ready">
100
+ <div class="tag ok">Key loaded</div>
101
+ <dl class="kv" style="margin-top:10px">
102
+ <dt>Approver</dt><dd class="who" id="who"></dd>
103
+ </dl>
104
+ <button class="ghost" id="forget">Remove key from this device</button>
105
+ </div>
106
+
107
+ <!-- --------------------------------------------------------- approval -->
108
+ <div class="panel">
109
+ <label for="xdr">Pending transaction (paste the XDR from the seller)</label>
110
+ <textarea id="xdr" autocorrect="off" autocapitalize="off" spellcheck="false"
111
+ placeholder="Paste the transaction the seller sent you"></textarea>
112
+ <button id="read">Read it</button>
113
+ <div class="err" id="readErr"></div>
114
+ </div>
115
+
116
+ <div class="panel hidden" id="review">
117
+ <div class="amount" id="amount"></div>
118
+ <dl class="kv">
119
+ <dt>Token</dt><dd id="token"></dd>
120
+ <dt>Payee</dt><dd id="payee"></dd>
121
+ <dt>From</dt><dd id="vault"></dd>
122
+ <dt>Reference</dt><dd id="memo"></dd>
123
+ <dt>Deadline</dt><dd id="deadline"></dd>
124
+ <dt>Network</dt><dd id="network"></dd>
125
+ <dt>Hash</dt><dd id="hash"></dd>
126
+ </dl>
127
+ <div class="hint">
128
+ Only the hash is signed. If anything above differs from what the seller told you,
129
+ do not approve &mdash; a changed amount or payee produces a different hash.
130
+ </div>
131
+ <button id="approve">Approve &amp; sign</button>
132
+ <button class="ghost" id="reject">Reject</button>
133
+ <div class="err" id="signErr"></div>
134
+ </div>
135
+
136
+ <!-- ----------------------------------------------------------- result -->
137
+ <div class="panel hidden" id="result">
138
+ <div class="tag ok">Signed</div>
139
+ <label>Signed transaction &mdash; send this back to the seller</label>
140
+ <div class="out" id="signed"></div>
141
+ <button id="copy">Copy</button>
142
+ <button class="ghost" id="again">Done</button>
143
+ </div>
144
+
145
+ <script src="xdr.js"></script>
146
+ <script src="app.js"></script>
147
+ </body>
148
+ </html>
@@ -0,0 +1,436 @@
1
+ // Minimal Stellar transaction codec for the phone. No dependencies, no network.
2
+ //
3
+ // The phone must be able to (a) read a payment so a human can see what they are
4
+ // approving, (b) recompute the transaction hash so it can prove the bytes it was
5
+ // given are the bytes it was shown, and (c) append its signature.
6
+ //
7
+ // It deliberately understands only the payment envelope shape. Anything it does
8
+ // not recognise is an error, not a guess — refusing to sign is always safe.
9
+ //
10
+ // Field order below is taken from the Stellar XDR schema (Transaction,
11
+ // Operation, PaymentOp, Preconditions, Memo, Asset, TransactionExt,
12
+ // DecoratedSignature). phone-app/xdr-check.ts asserts this decoder agrees with
13
+ // the official SDK on a real vault payment.
14
+ //
15
+ // This file runs unchanged in the browser and in Node (tests).
16
+ (function (root) {
17
+ "use strict";
18
+
19
+ // ------------------------------------------------------------------ reader
20
+ function Reader(bytes) {
21
+ this.b = bytes; this.i = 0;
22
+ }
23
+ Reader.prototype.u32 = function () {
24
+ if (this.i + 4 > this.b.length) throw new Error("xdr: truncated reading uint32");
25
+ const v = (this.b[this.i] << 24 | this.b[this.i + 1] << 16 | this.b[this.i + 2] << 8 | this.b[this.i + 3]) >>> 0;
26
+ this.i += 4; return v;
27
+ };
28
+ // Sequence numbers and amounts are int64 and routinely exceed
29
+ // Number.MAX_SAFE_INTEGER, so 64-bit values stay BigInt until displayed.
30
+ Reader.prototype.u64 = function () {
31
+ const hi = BigInt(this.u32()), lo = BigInt(this.u32());
32
+ return (hi << 32n) | lo;
33
+ };
34
+ Reader.prototype.i64 = function () {
35
+ const v = this.u64();
36
+ return v >= (1n << 63n) ? v - (1n << 64n) : v;
37
+ };
38
+ Reader.prototype.fixed = function (n) {
39
+ if (this.i + n > this.b.length) throw new Error("xdr: truncated fixed bytes");
40
+ const out = this.b.slice(this.i, this.i + n);
41
+ this.i += n;
42
+ const pad = (4 - (n % 4)) % 4;
43
+ for (let p = 0; p < pad; p++) {
44
+ if (this.b[this.i + p] !== 0) throw new Error("xdr: bad padding");
45
+ }
46
+ this.i += pad;
47
+ return out;
48
+ };
49
+ Reader.prototype.varOpaque = function (max) {
50
+ const n = this.u32();
51
+ if (n > max) throw new Error(`xdr: opaque length ${n} exceeds the ${max}-byte limit`);
52
+ return this.fixed(n);
53
+ };
54
+
55
+ // ------------------------------------------------------------------ writer
56
+ function Writer() { this.b = []; }
57
+ Writer.prototype.u32 = function (v) {
58
+ this.b.push((v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255);
59
+ };
60
+ Writer.prototype.u64 = function (v) {
61
+ const b = BigInt(v);
62
+ this.u32(Number((b >> 32n) & 0xffffffffn)); this.u32(Number(b & 0xffffffffn));
63
+ };
64
+ Writer.prototype.fixed = function (bytes) {
65
+ for (const x of bytes) this.b.push(x);
66
+ const pad = (4 - (bytes.length % 4)) % 4;
67
+ for (let p = 0; p < pad; p++) this.b.push(0);
68
+ };
69
+ Writer.prototype.varOpaque = function (bytes) { this.u32(bytes.length); this.fixed(bytes); };
70
+ Writer.prototype.bytes = function () { return Uint8Array.from(this.b); };
71
+
72
+ // ------------------------------------------------------------------ constants
73
+ const ENVELOPE_TYPE_TX = 2; // TransactionEnvelope holding a v1 tx
74
+ const KEY_TYPE_ED25519 = 0;
75
+ const KEY_TYPE_MUXED_ED25519 = 256;
76
+ const SC_ADDRESS = 4; // soroban account: we refuse it
77
+ const ASSET_NATIVE = 0, ASSET_ALPHANUM4 = 1, ASSET_ALPHANUM12 = 2;
78
+ const OP_PAYMENT = 1;
79
+ const PRECOND_NONE = 0, PRECOND_TIME = 1, PRECOND_V2 = 2;
80
+ const MEMO_NONE = 0, MEMO_TEXT = 1, MEMO_ID = 2, MEMO_HASH = 3, MEMO_RETURN = 4;
81
+ const STROOPS = 10000000;
82
+
83
+ // ------------------------------------------------------------------ strkey
84
+ const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
85
+
86
+ /** Stellar account id (G...) from a 32-byte ed25519 public key. */
87
+ function encodeAccountEd25519(pub) {
88
+ if (pub.length !== 32) throw new Error("public key must be 32 bytes");
89
+ return encodeStrKey(6 << 3, pub);
90
+ }
91
+
92
+ /** 32-byte public key from a Stellar account id. */
93
+ function decodeAccountEd25519(str) {
94
+ if (typeof str !== "string" || str[0] !== "G") {
95
+ throw new Error("not a Stellar account id (expected G...)");
96
+ }
97
+ return decodeStrKey(str, 6 << 3, 32);
98
+ }
99
+
100
+ /**
101
+ * Stellar secret seed (S...) -> 32-byte seed. Same base32 envelope as an
102
+ * account id, different version byte.
103
+ */
104
+ function decodeSecretSeed(str) {
105
+ if (typeof str !== "string" || str[0] !== "S") {
106
+ throw new Error("not a Stellar secret seed (expected S...)");
107
+ }
108
+ return decodeStrKey(str, 18 << 3, 32);
109
+ }
110
+
111
+ /** Stellar secret seed (S...) from a 32-byte seed. */
112
+ function encodeSecretSeed(seed) {
113
+ if (seed.length !== 32) throw new Error("seed must be 32 bytes");
114
+ return encodeStrKey(18 << 3, seed);
115
+ }
116
+
117
+ function encodeStrKey(version, body) {
118
+ const bytes = [version, ...body, 0, 0];
119
+ const crc = crc16Xmodem(bytes.slice(0, 1 + body.length));
120
+ bytes[bytes.length - 2] = crc & 255;
121
+ bytes[bytes.length - 1] = (crc >> 8) & 255;
122
+ const bits = [];
123
+ for (const byte of bytes) for (let k = 7; k >= 0; k--) bits.push((byte >> k) & 1);
124
+ let out = "";
125
+ for (let k = 0; k + 5 <= bits.length; k += 5) {
126
+ out += B32[bits[k] * 16 + bits[k + 1] * 8 + bits[k + 2] * 4 + bits[k + 3] * 2 + bits[k + 4]];
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function decodeStrKey(str, version, bodyLen) {
132
+ if (str.length !== 56) throw new Error("strkey must be 56 characters");
133
+ const bits = [];
134
+ for (const ch of str) {
135
+ const v = B32.indexOf(ch);
136
+ if (v < 0) throw new Error("not a valid strkey: bad character " + ch);
137
+ for (let k = 4; k >= 0; k--) bits.push((v >> k) & 1);
138
+ }
139
+ const bytes = [];
140
+ for (let k = 0; k + 8 <= bits.length; k += 8) {
141
+ let byte = 0;
142
+ for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[k + j];
143
+ bytes.push(byte);
144
+ }
145
+ if (bytes[0] !== version) throw new Error("wrong strkey version byte");
146
+ const body = Uint8Array.from(bytes.slice(1, 1 + bodyLen));
147
+ const checksum = (bytes[1 + bodyLen] | (bytes[2 + bodyLen] << 8)) >>> 0;
148
+ if (checksum !== crc16Xmodem(bytes.slice(0, 1 + bodyLen))) {
149
+ throw new Error("strkey checksum does not match — the key was mistyped");
150
+ }
151
+ return body;
152
+ }
153
+
154
+ /** CRC16-XMODEM, the checksum Stellar strkeys use. */
155
+ function crc16Xmodem(bytes) {
156
+ let crc = 0;
157
+ for (const b of bytes) {
158
+ crc ^= b << 8;
159
+ for (let i = 0; i < 8; i++) {
160
+ crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
161
+ }
162
+ }
163
+ return crc & 0xffff;
164
+ }
165
+
166
+ /**
167
+ * Turn a Stellar secret seed into a WebCrypto signing key.
168
+ *
169
+ * The result is NON-extractable: the app can sign with it, but nothing — not
170
+ * the page's own JavaScript, not an injected script — can read the seed back
171
+ * out of it. That is the point of the phone holding the approval half.
172
+ * The public half is captured at enrolment and stored separately.
173
+ */
174
+ async function importSecretSeed(seedStr) {
175
+ const seed = decodeSecretSeed(seedStr);
176
+ const pkcs8 = new Uint8Array(16 + 32);
177
+ pkcs8.set([
178
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
179
+ 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
180
+ ], 0);
181
+ pkcs8.set(seed, 16);
182
+ const privateKey = await crypto.subtle.importKey(
183
+ "pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
184
+ seed.fill(0); // drop the plaintext copy as soon as we can
185
+ return privateKey;
186
+ }
187
+
188
+ // ------------------------------------------------------------------ parsing
189
+ function readMuxedAccount(r) {
190
+ const type = r.u32();
191
+ if (type === KEY_TYPE_ED25519) return { kind: "ed25519", pub: r.fixed(32) };
192
+ if (type === KEY_TYPE_MUXED_ED25519) {
193
+ const id = r.u64();
194
+ const pub = r.fixed(32);
195
+ return { kind: "muxed", id: id.toString(), pub };
196
+ }
197
+ if (type === SC_ADDRESS) throw new Error("a smart-contract address cannot approve a payment");
198
+ throw new Error("unknown account key type " + type);
199
+ }
200
+
201
+ function readAsset(r) {
202
+ const type = r.u32();
203
+ if (type === ASSET_NATIVE) return { code: "XLM", issuer: null };
204
+ if (type === ASSET_ALPHANUM4 || type === ASSET_ALPHANUM12) {
205
+ const code = asciiTrim(r.fixed(type === ASSET_ALPHANUM4 ? 4 : 12));
206
+ return { code, issuer: encodeAccountEd25519(readPublicKey(r)) };
207
+ }
208
+ throw new Error("asset type " + type + " is not supported; refusing to sign");
209
+ }
210
+
211
+ /**
212
+ * Asset issuers use public_key, which is a union: a 4-byte key-type word
213
+ * before the 32 bytes. Missing that word shifts every later field by four
214
+ * bytes and silently misreads the amount, so it is read explicitly.
215
+ */
216
+ function readPublicKey(r) {
217
+ const type = r.u32();
218
+ if (type !== KEY_TYPE_ED25519) {
219
+ throw new Error("issuer key type " + type + " is not supported; refusing to sign");
220
+ }
221
+ return r.fixed(32);
222
+ }
223
+
224
+ function asciiTrim(bytes) {
225
+ let s = "";
226
+ for (const b of bytes) { if (b === 0) break; s += String.fromCharCode(b); }
227
+ if (!/^[A-Za-z0-9]{1,12}$/.test(s)) {
228
+ throw new Error("asset code is not alphanumeric: " + JSON.stringify(s));
229
+ }
230
+ return s;
231
+ }
232
+
233
+ function readMemo(r) {
234
+ const type = r.u32();
235
+ if (type === MEMO_NONE) return null;
236
+ if (type === MEMO_TEXT) {
237
+ const s = r.varOpaque(28);
238
+ let text = "";
239
+ for (const b of s) {
240
+ if (b < 0x20 || b > 0x7e) throw new Error("memo text is not printable");
241
+ text += String.fromCharCode(b);
242
+ }
243
+ return text;
244
+ }
245
+ if (type === MEMO_ID) return "id:" + r.u64().toString();
246
+ if (type === MEMO_HASH) return "hash:" + hex(r.fixed(32));
247
+ if (type === MEMO_RETURN) return "return:" + hex(r.fixed(32));
248
+ throw new Error("unknown memo type " + type);
249
+ }
250
+
251
+ function readPreconditions(r) {
252
+ const type = r.u32();
253
+ if (type === PRECOND_NONE) return null;
254
+ if (type === PRECOND_TIME) {
255
+ const minTime = r.u64().toString(), maxTime = r.u64().toString();
256
+ return { minTime, maxTime };
257
+ }
258
+ if (type === PRECOND_V2) {
259
+ // timeBounds? ledgerBounds? minSeqNum? minSeqAge minSeqLedgerGap extraSigners[2]
260
+ const hasTime = r.u32();
261
+ const bounds = hasTime
262
+ ? { minTime: r.u64().toString(), maxTime: r.u64().toString() }
263
+ : null;
264
+ const hasLedger = r.u32();
265
+ if (hasLedger) throw new Error("ledger bounds are not supported; refusing to sign");
266
+ const hasSeq = r.u32();
267
+ if (hasSeq) throw new Error("sequence preconditions are not supported; refusing to sign");
268
+ r.u64(); // minSeqAge
269
+ r.u32(); // minSeqLedgerGap
270
+ const n = r.u32();
271
+ if (n !== 0) throw new Error("extra signers are not supported; refusing to sign");
272
+ return bounds;
273
+ }
274
+ throw new Error("unknown precondition type " + type);
275
+ }
276
+
277
+ function readOperation(r) {
278
+ const hasSource = r.u32();
279
+ const source = hasSource ? readMuxedAccount(r) : null;
280
+ const type = r.u32(); // OperationBody union switch
281
+ if (type !== OP_PAYMENT) {
282
+ throw new Error("operation type " + type + " is not a payment; this app only signs payments");
283
+ }
284
+ const destination = readMuxedAccount(r);
285
+ const asset = readAsset(r);
286
+ const amount = r.i64();
287
+ if (amount < 0n) throw new Error("payment amount is negative; refusing to sign");
288
+ return { type, source, destination, asset, amount: amount.toString() };
289
+ }
290
+
291
+ function readTransaction(r) {
292
+ const source = readMuxedAccount(r);
293
+ const fee = r.u32();
294
+ const seq = r.u64();
295
+ const bounds = readPreconditions(r);
296
+ const memo = readMemo(r);
297
+ const numOps = r.u32();
298
+ if (numOps !== 1) {
299
+ throw new Error("this app signs single-payment transactions, and this one has "
300
+ + numOps + " operations");
301
+ }
302
+ const ops = [];
303
+ for (let i = 0; i < numOps; i++) ops.push(readOperation(r));
304
+ // TransactionExt is a union: v0 carries nothing at all.
305
+ const ext = r.u32();
306
+ if (ext !== 0) throw new Error("transaction carries soroban data; refusing to sign");
307
+ return { source, fee, seq, bounds, memo, ops };
308
+ }
309
+
310
+ function readDecorativeSignature(r) {
311
+ const hint = r.fixed(4);
312
+ const sig = r.varOpaque(64);
313
+ return { hint, sig };
314
+ }
315
+
316
+ /** Parse a base64 TransactionEnvelope, returning the tx plus its raw bytes. */
317
+ function parseEnvelope(base64) {
318
+ const bytes = base64ToBytes(base64);
319
+ const r = new Reader(bytes);
320
+ const type = r.u32();
321
+ if (type !== ENVELOPE_TYPE_TX) {
322
+ throw new Error("expected a v1 transaction envelope (type 2), got " + type
323
+ + " — a fee-bump or v0 envelope is not supported");
324
+ }
325
+ const txStart = r.i;
326
+ const tx = readTransaction(r);
327
+ const txEnd = r.i;
328
+ const n = r.u32();
329
+ if (n === 0 || n > 20) throw new Error("envelope has " + n + " signatures; expected 1 or 2");
330
+ const signatures = [];
331
+ for (let i = 0; i < n; i++) signatures.push(readDecorativeSignature(r));
332
+ if (r.i !== bytes.length) {
333
+ throw new Error("trailing bytes after the envelope (" + (bytes.length - r.i) + ")");
334
+ }
335
+ return { tx, signatures, txBytes: bytes.slice(txStart, txEnd), bytes };
336
+ }
337
+
338
+ // ------------------------------------------------------------------ hashing
339
+ /**
340
+ * The hash a Stellar signature covers:
341
+ * sha256( sha256(passphrase) || ENVELOPE_TYPE_TX(4) || transactionBytes )
342
+ *
343
+ * The leading 32 bytes are the hash of the network passphrase, not the
344
+ * passphrase itself. Recomputing this on the phone is what stops a swapped
345
+ * transaction: the human is shown this value, and the bytes actually signed
346
+ * have to produce it.
347
+ */
348
+ async function transactionHash(base64, passphraseStr) {
349
+ const env = parseEnvelope(base64);
350
+ const networkId = new Uint8Array(await crypto.subtle.digest(
351
+ "SHA-256", new TextEncoder().encode(passphraseStr)));
352
+ const buf = new Uint8Array(32 + 4 + env.txBytes.length);
353
+ buf.set(networkId, 0);
354
+ buf[32] = 0; buf[33] = 0; buf[34] = 0; buf[35] = ENVELOPE_TYPE_TX;
355
+ buf.set(env.txBytes, 36);
356
+ const digest = await crypto.subtle.digest("SHA-256", buf);
357
+ return { hash: hex(new Uint8Array(digest)), env };
358
+ }
359
+
360
+ // ------------------------------------------------------------------ signing
361
+ /**
362
+ * The bytes an approver signature must cover: the transaction hash itself,
363
+ * as 32 raw bytes. NOT sha256 of the hex text the human reads. Signing the
364
+ * wrong preimage produces a well-formed envelope the ledger refuses with
365
+ * BAD_AUTH, so this is exported rather than left to each caller to guess.
366
+ */
367
+ function signaturePreimage(hashHex) { return hexToBytes(hashHex); }
368
+
369
+ /** Append one ed25519 signature to the envelope. Returns new base64 XDR. */
370
+ function addSignature(base64, signatureBytes, pubKeyBytes) {
371
+ if (signatureBytes.length !== 64) throw new Error("signature must be 64 bytes");
372
+ if (pubKeyBytes.length !== 32) throw new Error("public key must be 32 bytes");
373
+ const env = parseEnvelope(base64);
374
+ if (env.signatures.length >= 2) {
375
+ throw new Error("envelope already has " + env.signatures.length + " signatures");
376
+ }
377
+ const w = new Writer();
378
+ w.u32(ENVELOPE_TYPE_TX);
379
+ w.fixed(env.txBytes);
380
+ const sigs = env.signatures.concat([{
381
+ hint: pubKeyBytes.slice(28), sig: signatureBytes,
382
+ }]);
383
+ w.u32(sigs.length);
384
+ for (const s of sigs) { w.fixed(s.hint); w.varOpaque(s.sig); }
385
+ return bytesToBase64(w.bytes());
386
+ }
387
+
388
+ // ------------------------------------------------------------------ amounts
389
+ /** int64 stroops -> "4.5000000", the way the ledger prints it. */
390
+ function formatAmount(stroopAmount) {
391
+ const v = typeof stroopAmount === "bigint" ? stroopAmount : BigInt(stroopAmount);
392
+ if (v < 0n) throw new Error("negative payment amount");
393
+ const whole = v / BigInt(STROOPS);
394
+ const frac = v % BigInt(STROOPS);
395
+ return whole.toString() + "." + frac.toString().padStart(7, "0");
396
+ }
397
+
398
+ // ------------------------------------------------------------------ base64
399
+ function base64ToBytes(s) {
400
+ const bin = atob(s);
401
+ const out = new Uint8Array(bin.length);
402
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
403
+ return out;
404
+ }
405
+ function bytesToBase64(bytes) {
406
+ let bin = "";
407
+ for (const b of bytes) bin += String.fromCharCode(b);
408
+ return btoa(bin);
409
+ }
410
+ function hexToBytes(s) {
411
+ if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2) throw new Error("not a hex string");
412
+ const out = new Uint8Array(s.length / 2);
413
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
414
+ return out;
415
+ }
416
+
417
+ function hex(bytes) {
418
+ let s = "";
419
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
420
+ return s;
421
+ }
422
+
423
+ const NETWORK_ID = {
424
+ testnet: "Test SDF Network ; September 2015",
425
+ public: "Public Global Stellar Network ; September 2015",
426
+ };
427
+
428
+ root.PaydluxXdr = {
429
+ parseEnvelope, transactionHash, addSignature, formatAmount,
430
+ hex, hexToBytes, signaturePreimage,
431
+ encodeAccountEd25519, decodeAccountEd25519,
432
+ decodeSecretSeed, encodeSecretSeed, importSecretSeed,
433
+ NETWORK_ID, crc16Xmodem,
434
+ ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
435
+ };
436
+ })(typeof globalThis !== "undefined" ? globalThis : this);