@paydlux/agent 0.1.2 → 0.1.3

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
@@ -48,6 +48,24 @@ npx tsx stellar/story.ts --public # one unguessable link; approve from your ph
48
48
 
49
49
  No wallet, no faucet, no config.
50
50
 
51
+ ### The human key does not live on the seller's machine
52
+
53
+ The vault needs two signatures. If both keys sit in one process, that is really
54
+ one key. So the approval half is split off:
55
+
56
+ ```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
59
+ ```
60
+
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.
68
+
51
69
  Use it as a package instead of cloning:
52
70
 
53
71
  ```bash
package/dist/desk.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import type { StellarVault } from "./vault.ts";
3
+ import { type ApprovalChallenge } from "./phone.ts";
3
4
  /** A portal link is a capability: whoever holds it can approve. So it is long,
4
5
  * random, and compared without leaking timing. */
5
6
  export declare function newPortalKey(): string;
@@ -9,10 +10,14 @@ export type DeskRecord = {
9
10
  uri: string;
10
11
  price: string;
11
12
  body: string;
12
- status: "pending" | "approved" | "denied" | "settled" | "failed";
13
+ status: "pending" | "awaiting_phone" | "approved" | "denied" | "settled" | "failed";
13
14
  content?: string;
14
15
  tx?: string;
15
16
  reason?: string;
17
+ /** split mode: what the phone is asked to sign, and what it sent back */
18
+ challenge?: ApprovalChallenge;
19
+ qr?: string;
20
+ signedXdr?: string;
16
21
  };
17
22
  export type DeskStore = DeskRecord;
18
23
  export type DeskConfig = {
@@ -21,6 +26,14 @@ export type DeskConfig = {
21
26
  /** seeds of the two human keys. DEV ONLY — in production these live on the phone. */
22
27
  principal?: string;
23
28
  approver?: string;
29
+ /**
30
+ * Split signing: the desk holds only the agent/principal half. Approving
31
+ * produces a challenge the phone signs offline; the signature comes back and
32
+ * the desk submits it. The approver seed never touches this machine.
33
+ */
34
+ split?: boolean;
35
+ /** how long a human has to decide before the payment is dead */
36
+ decideWithinSeconds?: number;
24
37
  port: number;
25
38
  /** If set, the portal and its actions require this key in the URL.
26
39
  * Required before putting the desk on any public URL. */
@@ -29,15 +42,35 @@ export type DeskConfig = {
29
42
  export declare class ApprovalDesk {
30
43
  private records;
31
44
  private seq;
45
+ private server?;
32
46
  cfg: DeskConfig;
33
47
  constructor(cfg: DeskConfig);
34
48
  enqueue(uri: string, price: string, body: string, content: string): string;
35
49
  get(id: string): DeskRecord | null;
36
50
  list(): DeskRecord[];
37
- /** The human clicked Approve: sign and submit the release. */
51
+ /**
52
+ * The human clicked Approve.
53
+ *
54
+ * dev mode (approver seed present): sign and submit here. One machine holds
55
+ * both keys, which is fine for a testnet demo and nothing else.
56
+ * split mode (no approver seed): build the challenge, sign it with the
57
+ * principal only, and hand it to the phone. The money moves when
58
+ * /signed comes back with the phone's signature.
59
+ */
38
60
  approve(id: string): Promise<DeskRecord>;
61
+ /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
62
+ private requestPhoneSignature;
63
+ /**
64
+ * Split mode step 2: the phone answered. `signedXdr` is the transaction with
65
+ * the approver signature added, returned by signChallenge() on the phone.
66
+ * The desk cannot forge this: it does not hold the approver key, and the
67
+ * ledger rejects it if the signature is not over this exact transaction.
68
+ */
69
+ complete(id: string, signedXdr: string): Promise<DeskRecord>;
39
70
  deny(id: string, reason?: string): DeskRecord;
40
71
  /** Serve the pending list as JSON (the portal polls this). */
41
72
  api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
42
73
  listen(port?: number): Promise<void>;
74
+ /** Stop serving. Without this the process never exits. */
75
+ close(): Promise<void>;
43
76
  }
package/dist/desk.js CHANGED
@@ -12,10 +12,65 @@
12
12
  import { createServer } from "node:http";
13
13
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
14
  import { Keypair } from "@stellar/stellar-sdk";
15
+ import { TransactionBuilder } from "@stellar/stellar-sdk";
16
+ import { passphrase } from "./vault.js";
15
17
  import { portalPage } from "./portal.js";
18
+ import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
16
19
  /** A portal link is a capability: whoever holds it can approve. So it is long,
17
20
  * random, and compared without leaking timing. */
18
21
  export function newPortalKey() { return randomBytes(16).toString("base64url"); }
22
+ /**
23
+ * Check that a signature the phone sent back belongs to THIS payment.
24
+ * Returns a reason string if it does not, or null if it is fine.
25
+ */
26
+ function verifyAgainstChallenge(signedXdr, rec) {
27
+ const ch = rec.challenge;
28
+ let signed;
29
+ try {
30
+ signed = TransactionBuilder.fromXDR(signedXdr, passphrase(recNetwork(rec)));
31
+ }
32
+ catch {
33
+ return "the phone returned something that is not a valid transaction for this network";
34
+ }
35
+ // 1. same transaction the human was shown: same hash, so the amount, payee,
36
+ // source account and sequence are all identical to what was approved.
37
+ const hash = Buffer.from(signed.hash()).toString("hex");
38
+ if (hash !== ch.hash) {
39
+ return `signed a different transaction (${hash.slice(0, 12)}… was not issued for ${rec.id}, which is ${ch.hash.slice(0, 12)}…)`;
40
+ }
41
+ // 2. the phone added exactly one signature; nothing else changed.
42
+ if (signed.signatures.length < 2) {
43
+ return `only ${signed.signatures.length} signature(s); the vault needs 2`;
44
+ }
45
+ // 3. belt and braces: the payment still says what the record says.
46
+ const op = signed.operations?.[0];
47
+ if (!op || op.type !== "payment")
48
+ return "the transaction is not a payment";
49
+ if (op.destination !== rec.challenge.summary.payee)
50
+ return "payee does not match the approval";
51
+ const want = rec.challenge.summary.amount;
52
+ if (op.amount !== want && Number(op.amount) !== Number(want)) {
53
+ return `amount ${op.amount} does not match the approved ${want}`;
54
+ }
55
+ return null;
56
+ }
57
+ function recNetwork(rec) {
58
+ return rec.challenge.network;
59
+ }
60
+ function readBody(req) {
61
+ return new Promise((resolve, reject) => {
62
+ let data = "";
63
+ req.on("data", (c) => {
64
+ data += c;
65
+ if (data.length > 64_000) {
66
+ reject(new Error("body too large"));
67
+ req.destroy();
68
+ }
69
+ });
70
+ req.on("end", () => resolve(data));
71
+ req.on("error", reject);
72
+ });
73
+ }
19
74
  function same(a, b) {
20
75
  const x = Buffer.from(a), y = Buffer.from(b);
21
76
  return x.length === y.length && timingSafeEqual(x, y);
@@ -23,6 +78,7 @@ function same(a, b) {
23
78
  export class ApprovalDesk {
24
79
  records = new Map();
25
80
  seq = 0;
81
+ server;
26
82
  cfg;
27
83
  constructor(cfg) { this.cfg = cfg; }
28
84
  enqueue(uri, price, body, content) {
@@ -33,13 +89,23 @@ export class ApprovalDesk {
33
89
  }
34
90
  get(id) { return this.records.get(id) ?? null; }
35
91
  list() { return [...this.records.values()]; }
36
- /** The human clicked Approve: sign and submit the release. */
92
+ /**
93
+ * The human clicked Approve.
94
+ *
95
+ * dev mode (approver seed present): sign and submit here. One machine holds
96
+ * both keys, which is fine for a testnet demo and nothing else.
97
+ * split mode (no approver seed): build the challenge, sign it with the
98
+ * principal only, and hand it to the phone. The money moves when
99
+ * /signed comes back with the phone's signature.
100
+ */
37
101
  async approve(id) {
38
102
  const rec = this.records.get(id);
39
103
  if (!rec)
40
104
  throw new Error("unknown handle " + id);
41
105
  if (rec.status !== "pending")
42
106
  throw new Error(`already ${rec.status}`);
107
+ if (this.cfg.split)
108
+ return this.requestPhoneSignature(rec);
43
109
  if (!this.cfg.principal || !this.cfg.approver) {
44
110
  rec.status = "failed";
45
111
  rec.reason = "no human keys configured (portal signing not wired yet)";
@@ -56,6 +122,75 @@ export class ApprovalDesk {
56
122
  }
57
123
  return rec;
58
124
  }
125
+ /** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
126
+ async requestPhoneSignature(rec) {
127
+ if (!this.cfg.principal) {
128
+ rec.status = "failed";
129
+ rec.reason = "split mode needs the principal seed on the seller's side";
130
+ return rec;
131
+ }
132
+ try {
133
+ const challenge = await createApprovalChallenge({
134
+ vaultAddress: this.cfg.vault.address,
135
+ principal: Keypair.fromSecret(this.cfg.principal),
136
+ payee: this.cfg.payee,
137
+ amount: rec.price,
138
+ tokenCode: this.cfg.vault.token.getCode(),
139
+ tokenIssuer: this.cfg.vault.token.getIssuer() ?? "",
140
+ network: this.cfg.vault.network,
141
+ horizon: this.cfg.vault.horizon,
142
+ id: rec.id,
143
+ memo: rec.id,
144
+ decideWithinSeconds: this.cfg.decideWithinSeconds,
145
+ });
146
+ rec.challenge = challenge;
147
+ rec.qr = challengeQrPayload(challenge);
148
+ rec.status = "awaiting_phone";
149
+ console.log(`[desk] ${rec.id} sent to the phone: ${rec.price} for ${rec.uri}`);
150
+ }
151
+ catch (e) {
152
+ rec.status = "failed";
153
+ rec.reason = e.message;
154
+ }
155
+ return rec;
156
+ }
157
+ /**
158
+ * Split mode step 2: the phone answered. `signedXdr` is the transaction with
159
+ * the approver signature added, returned by signChallenge() on the phone.
160
+ * The desk cannot forge this: it does not hold the approver key, and the
161
+ * ledger rejects it if the signature is not over this exact transaction.
162
+ */
163
+ async complete(id, signedXdr) {
164
+ const rec = this.records.get(id);
165
+ if (!rec)
166
+ throw new Error("unknown handle " + id);
167
+ if (rec.status !== "awaiting_phone")
168
+ throw new Error(`not awaiting a phone signature (it is ${rec.status})`);
169
+ if (!rec.challenge)
170
+ throw new Error("no challenge was issued for " + id);
171
+ // Bind the answer to the question. Without this check the desk would submit
172
+ // any signed transaction posted to this route — including one the phone
173
+ // signed for a DIFFERENT payment, at a different price. That is how a
174
+ // payments bug becomes a wrong-amount bug.
175
+ const problem = verifyAgainstChallenge(signedXdr, rec);
176
+ if (problem) {
177
+ rec.status = "failed";
178
+ rec.reason = problem;
179
+ console.log(`[desk] ${id} rejected: ${problem}`);
180
+ return rec;
181
+ }
182
+ rec.signedXdr = signedXdr;
183
+ try {
184
+ const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
185
+ rec.status = "settled";
186
+ rec.tx = res.hash;
187
+ }
188
+ catch (e) {
189
+ rec.status = "failed";
190
+ rec.reason = e.message;
191
+ }
192
+ return rec;
193
+ }
59
194
  deny(id, reason = "denied by the approver") {
60
195
  const rec = this.records.get(id);
61
196
  if (!rec)
@@ -70,6 +205,7 @@ export class ApprovalDesk {
70
205
  api(handler) { return handler; }
71
206
  listen(port = this.cfg.port) {
72
207
  const srv = createServer(async (req, res) => {
208
+ // (server handle kept below so close() can stop it)
73
209
  const url = new URL(req.url ?? "/", "http://localhost");
74
210
  // A public demo link must not be an open approval button. If a portalKey
75
211
  // is configured, every route needs it, or the desk answers 403.
@@ -91,8 +227,15 @@ export class ApprovalDesk {
91
227
  }
92
228
  if (url.pathname === "/list")
93
229
  return send(200, this.list());
94
- const m = url.pathname.match(/^\/(approve|deny)\/([\w-]+)$/);
230
+ const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
95
231
  if (m && req.method === "POST") {
232
+ if (m[1] === "signed") {
233
+ const body = await readBody(req);
234
+ const { signedXdr } = JSON.parse(body || "{}");
235
+ if (!signedXdr)
236
+ return send(400, { error: "signedXdr is required" });
237
+ return send(200, await this.complete(m[2], String(signedXdr)));
238
+ }
96
239
  const rec = m[1] === "approve" ? await this.approve(m[2]) : this.deny(m[2]);
97
240
  return send(200, rec);
98
241
  }
@@ -102,6 +245,15 @@ export class ApprovalDesk {
102
245
  return send(400, { error: e.message });
103
246
  }
104
247
  });
248
+ this.server = srv;
105
249
  return new Promise((r) => srv.listen(port, () => r()));
106
250
  }
251
+ /** Stop serving. Without this the process never exits. */
252
+ close() {
253
+ return new Promise((r) => {
254
+ if (!this.server)
255
+ return r();
256
+ this.server.close(() => r());
257
+ });
258
+ }
107
259
  }
package/dist/index.d.ts CHANGED
@@ -9,3 +9,6 @@ export type { DeskConfig, DeskRecord } from "./desk.ts";
9
9
  export { portalPage } from "./portal.ts";
10
10
  export { PolicyEngine, DEFAULT_POLICY } from "./policy.ts";
11
11
  export type { Policy, SpendEvent, Verdict } from "./policy.ts";
12
+ export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.ts";
13
+ export { txResultName } from "./vault.ts";
14
+ export type { ApprovalChallenge } from "./phone.ts";
package/dist/index.js CHANGED
@@ -7,3 +7,6 @@ export { PaydluxAgent } from "./agent.js";
7
7
  export { ApprovalDesk, newPortalKey } from "./desk.js";
8
8
  export { portalPage } from "./portal.js";
9
9
  export { PolicyEngine, DEFAULT_POLICY } from "./policy.js";
10
+ // Split signing: the human's key lives on a phone, not in the seller's process.
11
+ export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.js";
12
+ export { txResultName } from "./vault.js";
@@ -0,0 +1,65 @@
1
+ import { Keypair, StrKey } from "@stellar/stellar-sdk";
2
+ import type { Network } from "./vault.ts";
3
+ /** What the seller sends to the phone. Unsigned on purpose. */
4
+ export type ApprovalChallenge = {
5
+ id: string;
6
+ network: Network;
7
+ /** the transaction, as XDR, signed by the principal only */
8
+ xdr: string;
9
+ /** what a human should read before signing */
10
+ summary: {
11
+ payee: string;
12
+ amount: string;
13
+ token: string;
14
+ tokenIssuer: string;
15
+ from: string;
16
+ memo?: string;
17
+ expiresAt?: number;
18
+ };
19
+ hash: string;
20
+ };
21
+ /**
22
+ * Build the payment and sign it with the principal key only. The result is
23
+ * deliberately incomplete: it reaches the vault's threshold of 2 only once the
24
+ * approver's signature is added.
25
+ */
26
+ export declare function createApprovalChallenge(opts: {
27
+ vaultAddress: string;
28
+ principal: Keypair;
29
+ payee: string;
30
+ amount: string;
31
+ tokenCode: string;
32
+ tokenIssuer: string;
33
+ network: Network;
34
+ horizon: import("@stellar/stellar-sdk").Horizon.Server;
35
+ id: string;
36
+ memo?: string;
37
+ /** how long the human has to decide; the tx is dead after this */
38
+ decideWithinSeconds?: number;
39
+ }): Promise<ApprovalChallenge>;
40
+ /**
41
+ * What a person (or a phone app) does with a challenge: read it, decide, sign.
42
+ *
43
+ * This function does not talk to the network. It cannot submit anything. It
44
+ * cannot change the amount. It either adds a valid signature or returns the
45
+ * challenge untouched. That is the whole point of running it on the phone.
46
+ */
47
+ export declare function signChallenge(challenge: ApprovalChallenge, approverSeed: string): string;
48
+ /**
49
+ * Read a challenge without any secrets, to show a human. Deliberately separate
50
+ * from signing so a UI can render it on an untrusted machine.
51
+ */
52
+ export declare function describeChallenge(challenge: ApprovalChallenge): string;
53
+ /**
54
+ * The seller's side: take the phone's signature back and submit. Fails loudly
55
+ * if the transaction is still short of the vault's threshold.
56
+ */
57
+ export declare function submitApproved(signedXdr: string, network: Network, horizon: import("@stellar/stellar-sdk").Horizon.Server): Promise<{
58
+ hash: string;
59
+ ok: true;
60
+ }>;
61
+ /** A stable, scannable form of a challenge — what a phone would read from a QR code. */
62
+ export declare function challengeQrPayload(challenge: ApprovalChallenge): string;
63
+ /** Parse what a QR code carried. Rejects a payload whose hash disagrees. */
64
+ export declare function parseChallengeQr(payload: string): ApprovalChallenge;
65
+ export { StrKey };
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
@@ -23,6 +23,8 @@ 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}
26
28
  .foot{margin-top:28px;color:var(--muted);font-size:12px}
27
29
  @media(max-width:640px){.body{max-width:180px}}
28
30
  </style></head><body>
@@ -38,8 +40,9 @@ export function portalPage(records, vault, network, qs = "") {
38
40
  document.querySelector('tbody').innerHTML=d.map(row).join('')||'<tr><td colspan="5" class="empty">nothing waiting</td></tr>';
39
41
  document.getElementById('t').textContent=new Date().toLocaleTimeString()}
40
42
  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>'}
43
+ 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>':'';
44
+ const ph=x.status==='awaiting_phone'?'<div class="tx">awaiting your phone</div>':'';
45
+ 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
46
  setInterval(load,3000);
44
47
  </script></body></html>`;
45
48
  }
@@ -48,7 +51,13 @@ function render(r) {
48
51
  ? `<button class="yes" onclick="act('approve','${r.id}')">Approve</button>
49
52
  <button class="no" onclick="act('deny','${r.id}')">Deny</button>`
50
53
  : "";
51
- const meta = [r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
54
+ // Split mode: the desk has no approver key, so approving hands the payment to
55
+ // a phone. Show the human exactly what the phone will be asked to sign.
56
+ const phone = r.status === "awaiting_phone" && r.challenge
57
+ ? `<div class="tx">awaiting your phone · ${r.challenge.summary.amount} ${r.challenge.summary.token} to ${r.challenge.summary.payee.slice(0, 12)}…</div>
58
+ <details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${r.qr}</pre></details>`
59
+ : "";
60
+ const meta = [phone, r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
52
61
  return `<tr>
53
62
  <td>${new Date(r.at).toLocaleTimeString()}</td>
54
63
  <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.3",
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",