@paydlux/agent 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/phone.js ADDED
@@ -0,0 +1,163 @@
1
+ // Paydlux — the approval half that lives on a phone.
2
+ //
3
+ // The problem this solves: until now the human's key was a string in the
4
+ // seller's own process. That makes "2 of 2" a lie — one machine held both
5
+ // halves, so whoever owned that machine was the human.
6
+ //
7
+ // Split it the way banks and hardware wallets do:
8
+ //
9
+ // laptop (seller) builds the payment, signs with the principal key,
10
+ // sends the UNSIGNED transaction out
11
+ // phone (human) decodes it, shows a person what it says, signs with the
12
+ // approver key, sends the signature back
13
+ // laptop (seller) submits the now-complete transaction
14
+ //
15
+ // The approver seed never travels to the laptop. The phone needs no network to
16
+ // sign — it only needs the transaction, and it can refuse.
17
+ //
18
+ // The signature is over the transaction hash, so the phone is signing THIS
19
+ // payment: change the amount or the payee and the signature stops counting.
20
+ import { Asset, Keypair, Memo, Operation, StrKey, TransactionBuilder, } from "@stellar/stellar-sdk";
21
+ import { passphrase, StellarTxError, txResultName } from "./vault.js";
22
+ /**
23
+ * Build the payment and sign it with the principal key only. The result is
24
+ * deliberately incomplete: it reaches the vault's threshold of 2 only once the
25
+ * approver's signature is added.
26
+ */
27
+ export async function createApprovalChallenge(opts) {
28
+ const pass = passphrase(opts.network);
29
+ const acc = await opts.horizon.loadAccount(opts.vaultAddress);
30
+ const asset = opts.tokenCode === "XLM" ? Asset.native() : new Asset(opts.tokenCode, opts.tokenIssuer);
31
+ const built = new TransactionBuilder(acc, { fee: "100", networkPassphrase: pass })
32
+ .addOperation(Operation.payment({ destination: opts.payee, asset, amount: opts.amount }))
33
+ .setTimeout(opts.decideWithinSeconds ?? 1800);
34
+ if (opts.memo)
35
+ built.addMemo(Memo.text(opts.memo));
36
+ const tx = built.build();
37
+ tx.sign(opts.principal);
38
+ return {
39
+ id: opts.id,
40
+ network: opts.network,
41
+ xdr: tx.toXDR(),
42
+ summary: {
43
+ payee: opts.payee,
44
+ amount: opts.amount,
45
+ token: opts.tokenCode,
46
+ tokenIssuer: opts.tokenIssuer,
47
+ from: opts.vaultAddress,
48
+ memo: opts.memo,
49
+ expiresAt: tx.timeBounds ? Number(tx.timeBounds.maxTime) || undefined : undefined,
50
+ },
51
+ hash: Buffer.from(tx.hash()).toString("hex"),
52
+ };
53
+ }
54
+ /**
55
+ * What a person (or a phone app) does with a challenge: read it, decide, sign.
56
+ *
57
+ * This function does not talk to the network. It cannot submit anything. It
58
+ * cannot change the amount. It either adds a valid signature or returns the
59
+ * challenge untouched. That is the whole point of running it on the phone.
60
+ */
61
+ export function signChallenge(challenge, approverSeed) {
62
+ const pass = passphrase(challenge.network);
63
+ const tx = TransactionBuilder.fromXDR(challenge.xdr, pass);
64
+ const hash = Buffer.from(tx.hash()).toString("hex");
65
+ if (hash !== challenge.hash) {
66
+ throw new Error("challenge was altered in transit — hash does not match, refusing to sign");
67
+ }
68
+ tx.sign(Keypair.fromSecret(approverSeed));
69
+ return tx.toXDR();
70
+ }
71
+ /**
72
+ * Read a challenge without any secrets, to show a human. Deliberately separate
73
+ * from signing so a UI can render it on an untrusted machine.
74
+ */
75
+ export function describeChallenge(challenge) {
76
+ const s = challenge.summary;
77
+ return [
78
+ `pay ${s.amount} ${s.token}`,
79
+ `to ${s.payee}`,
80
+ `from ${s.from}`,
81
+ `issuer ${s.tokenIssuer}`,
82
+ s.memo ? `note ${readableMemo(s.memo)}` : null,
83
+ `network ${challenge.network}`,
84
+ `signs ${challenge.hash.slice(0, 16)}…`,
85
+ ].filter(Boolean).join("\n");
86
+ }
87
+ /**
88
+ * The seller's side: take the phone's signature back and submit. Fails loudly
89
+ * if the transaction is still short of the vault's threshold.
90
+ */
91
+ export async function submitApproved(signedXdr, network, horizon) {
92
+ const pass = passphrase(network);
93
+ const tx = TransactionBuilder.fromXDR(signedXdr, pass);
94
+ if (!tx.signatures.length)
95
+ throw new Error("no signatures on the returned transaction");
96
+ try {
97
+ await horizon.submitTransaction(tx);
98
+ return { hash: Buffer.from(tx.hash()).toString("hex"), ok: true };
99
+ }
100
+ catch (e) {
101
+ // Horizon answers 400 with the reason buried in the result codes. Surface
102
+ // the ledger's own word (BAD_AUTH, OP_INNER, ...) instead of "400".
103
+ let codes;
104
+ try {
105
+ codes = e?.resultResultCodes?.()?.results;
106
+ }
107
+ catch { /* shape differs */ }
108
+ codes ??= e?.response?.data?.extras?.result_codes?.results;
109
+ const resultXdr = e?.response?.data?.extras?.result_xdr;
110
+ const detail = codes?.join(",")
111
+ || (resultXdr ? txResultName(resultXdr) : undefined)
112
+ || resultXdr
113
+ || e.message;
114
+ throw new StellarTxError(detail, e);
115
+ }
116
+ }
117
+ /**
118
+ * A memo can arrive as text or as raw bytes depending on how the transaction was
119
+ * decoded. Show a human words, never a byte list.
120
+ */
121
+ function readableMemo(memo) {
122
+ if (typeof memo === "string")
123
+ return memo;
124
+ if (Array.isArray(memo) || memo instanceof Uint8Array) {
125
+ try {
126
+ return Buffer.from(memo).toString("utf8");
127
+ }
128
+ catch {
129
+ return String(memo);
130
+ }
131
+ }
132
+ return String(memo ?? "");
133
+ }
134
+ /** A stable, scannable form of a challenge — what a phone would read from a QR code. */
135
+ export function challengeQrPayload(challenge) {
136
+ return `paydlux:1\n${challenge.id}\n${challenge.network}\n${challenge.hash}\n${challenge.xdr}`;
137
+ }
138
+ /** Parse what a QR code carried. Rejects a payload whose hash disagrees. */
139
+ export function parseChallengeQr(payload) {
140
+ const [tag, id, network, hash, xdr] = payload.split("\n");
141
+ if (tag !== "paydlux:1")
142
+ throw new Error("not a paydlux approval payload");
143
+ if (network !== "testnet" && network !== "public")
144
+ throw new Error("unknown network");
145
+ const tx = TransactionBuilder.fromXDR(xdr, passphrase(network));
146
+ const actual = Buffer.from(tx.hash()).toString("hex");
147
+ if (actual !== hash)
148
+ throw new Error("payload was tampered with: hash mismatch");
149
+ const op = tx.operations[0];
150
+ const asset = op.asset;
151
+ return {
152
+ id, network: network, xdr, hash,
153
+ summary: {
154
+ payee: op.destination,
155
+ amount: op.amount,
156
+ token: asset?.getCode ? asset.getCode() : "XLM",
157
+ tokenIssuer: asset?.getIssuer ? (asset.getIssuer() ?? "native") : "native",
158
+ from: tx.source,
159
+ memo: readableMemo(tx.memo?.value),
160
+ },
161
+ };
162
+ }
163
+ export { StrKey };
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">
@@ -23,6 +23,9 @@ export function portalPage(records, vault, network, qs = "") {
23
23
  .st{font-size:12px;text-transform:uppercase;letter-spacing:.06em}
24
24
  .settled{color:var(--green)} .denied,.failed{color:#e07a6b} .pending{color:var(--gold)}
25
25
  .tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
26
+ .qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
27
+ details summary{cursor:pointer}
28
+ .phlink{color:var(--gold);text-decoration:underline;font-size:12px}
26
29
  .foot{margin-top:28px;color:var(--muted);font-size:12px}
27
30
  @media(max-width:640px){.body{max-width:180px}}
28
31
  </style></head><body>
@@ -38,17 +41,31 @@ export function portalPage(records, vault, network, qs = "") {
38
41
  document.querySelector('tbody').innerHTML=d.map(row).join('')||'<tr><td colspan="5" class="empty">nothing waiting</td></tr>';
39
42
  document.getElementById('t').textContent=new Date().toLocaleTimeString()}
40
43
  function row(x){const st='<span class="st '+x.status+'">'+x.status+'</span>'+(x.tx?'<div class="tx">'+x.tx+'</div>':'')+(x.reason?'<div class="tx">'+x.reason+'</div>':'');
41
- const btns=x.status==='pending'?'<button class="yes" onclick="act(\\'approve\\',\\''+x.id+'\\')">Approve</button><button class="no" onclick="act(\\'deny\\',\\''+x.id+'\\')">Deny</button>':'';
42
- return '<tr><td>'+new Date(x.at).toLocaleTimeString()+'</td><td><div class="uri">'+x.uri+'</div><div class="body">'+(x.body||'')+'</div></td><td class="amt">'+x.price+'</td><td>'+st+'</td><td style="min-width:130px">'+btns+'</td></tr>'}
44
+ const btns=x.status==='pending'?'<button class="yes" onclick="act(\'approve\',\''+x.id+'\')">Approve</button><button class="no" onclick="act(\'deny\',\''+x.id+'\')">Deny</button>':'';
45
+ const ph=x.status==='awaiting_phone'?'<div class="tx">awaiting your phone</div>':'';
46
+ return '<tr><td>'+new Date(x.at).toLocaleTimeString()+'</td><td><div class="uri">'+x.uri+'</div><div class="body">'+(x.body||'')+'</div></td><td class="amt">'+x.price+'</td><td>'+st+ph+'</td><td style="min-width:130px">'+btns+'</td></tr>'}
43
47
  setInterval(load,3000);
44
48
  </script></body></html>`;
45
49
  }
46
- 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("?", "&")}`;
47
54
  const btns = r.status === "pending"
48
55
  ? `<button class="yes" onclick="act('approve','${r.id}')">Approve</button>
49
56
  <button class="no" onclick="act('deny','${r.id}')">Deny</button>`
50
57
  : "";
51
- const meta = [r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
58
+ // Split mode: the desk has no approver key, so approving hands the payment to
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.
63
+ const phone = r.status === "awaiting_phone" && r.challenge
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>
66
+ <details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${r.qr}</pre></details>`
67
+ : "";
68
+ const meta = [phone, r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
52
69
  return `<tr>
53
70
  <td>${new Date(r.at).toLocaleTimeString()}</td>
54
71
  <td><div class="uri">${r.uri}</div><div class="body">${r.body || ""}</div></td>
package/dist/vault.d.ts CHANGED
@@ -8,7 +8,8 @@ export declare function hex(b: Uint8Array | string): string;
8
8
  * only these two accounts are real money. Never accept any other issuer.
9
9
  */
10
10
  export declare const USDC_ISSUER: Record<Network, string>;
11
- /** A rejected Stellar transaction, with the ledger's own reason code. */
11
+ /** Turn Horizon's result_xdr into a readable code, e.g. "BAD_AUTH". */
12
+ export declare function txResultName(resultXdr: string): string | undefined;
12
13
  export declare class StellarTxError extends Error {
13
14
  code: string;
14
15
  constructor(code: string, cause?: unknown);
package/dist/vault.js CHANGED
@@ -29,6 +29,31 @@ export const USDC_ISSUER = {
29
29
  testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
30
30
  };
31
31
  /** A rejected Stellar transaction, with the ledger's own reason code. */
32
+ // Horizon hands back the transaction result as XDR:
33
+ // struct TransactionResult { int64 feeCharged; TransactionResultCode resultCode; ... }
34
+ // so the code is the signed int32 at byte offset 8, not the first 8 bytes (that
35
+ // is the fee). -6 = txBAD_AUTH = "not enough signatures", which is exactly what
36
+ // a half-signed vault payment looks like.
37
+ const TX_RESULT_CODES = {
38
+ 0: "SUCCESS", [-1]: "FAILED", [-2]: "TOO_EARLY", [-3]: "TOO_LATE",
39
+ [-4]: "MISSING_OPERATION", [-5]: "BAD_SEQ", [-6]: "BAD_AUTH",
40
+ [-7]: "INSUFFICIENT_BALANCE", [-8]: "NO_ACCOUNT", [-9]: "INSUFFICIENT_FEE",
41
+ [-10]: "BAD_AUTH_EXTRA", [-11]: "INTERNAL_ERROR", [-12]: "NOT_SUPPORTED",
42
+ [-13]: "ACCOUNT_EXISTS", [-14]: "INSUFFICIENT_REWARDS",
43
+ };
44
+ /** Turn Horizon's result_xdr into a readable code, e.g. "BAD_AUTH". */
45
+ export function txResultName(resultXdr) {
46
+ try {
47
+ const b = Buffer.from(resultXdr, "base64");
48
+ if (b.length < 12)
49
+ return undefined;
50
+ const n = b.readInt32BE(8);
51
+ return TX_RESULT_CODES[n] ?? `code ${n}`;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
32
57
  export class StellarTxError extends Error {
33
58
  code;
34
59
  constructor(code, cause) {
@@ -293,7 +318,9 @@ async function sub(h, _pass, tx, ...signers) {
293
318
  }
294
319
  catch { /* shape differs */ }
295
320
  codes ??= e?.response?.data?.extras?.result_codes?.results;
296
- const detail = codes?.join(",") || e?.response?.data?.extras?.result_xdr || e?.message || String(e);
321
+ const resultXdr = e?.response?.data?.extras?.result_xdr;
322
+ const named = resultXdr ? txResultName(resultXdr) : undefined;
323
+ const detail = codes?.join(",") || named || resultXdr || e?.message || String(e);
297
324
  throw new StellarTxError(detail, e);
298
325
  }
299
326
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.2",
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
+ })();