@paydlux/agent 0.1.1 → 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 +28 -2
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/desk.d.ts +35 -2
- package/dist/desk.js +155 -3
- package/dist/gateway.d.ts +3 -3
- package/dist/gateway.js +5 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/phone.d.ts +65 -0
- package/dist/phone.js +163 -0
- package/dist/portal.js +12 -3
- package/dist/vault.d.ts +5 -2
- package/dist/vault.js +34 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -46,13 +46,39 @@ npx tsx stellar/demo.ts # 12 checks, live testnet
|
|
|
46
46
|
npx tsx stellar/story.ts --public # one unguessable link; approve from your phone
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
No wallet, no faucet, no config.
|
|
50
|
-
|
|
49
|
+
No wallet, no faucet, no config.
|
|
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
|
+
|
|
69
|
+
Use it as a package instead of cloning:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install @paydlux/agent
|
|
73
|
+
```
|
|
51
74
|
|
|
52
75
|
```ts
|
|
53
76
|
import { StellarVault, Gateway, PaydluxAgent } from "@paydlux/agent";
|
|
54
77
|
```
|
|
55
78
|
|
|
79
|
+
`npm run build` produces `dist/`. The package depends only on
|
|
80
|
+
`@stellar/stellar-sdk` — no transitive `toml`, so `npm audit` is clean.
|
|
81
|
+
|
|
56
82
|
`story.ts --public` matters for demos: the portal has no login, so the printed
|
|
57
83
|
link carries a 22-character key and every request without it gets a 403.
|
|
58
84
|
|
package/dist/agent.d.ts
CHANGED
package/dist/agent.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The agent holds one key of two. It can ask, and it can execute a mandate a
|
|
7
7
|
// human already signed. It cannot move money on its own — the ledger refuses.
|
|
8
|
-
import { Horizon, Keypair, Transaction } from "stellar-sdk";
|
|
8
|
+
import { Horizon, Keypair, Transaction } from "@stellar/stellar-sdk";
|
|
9
9
|
import { passphrase } from "./vault.js";
|
|
10
10
|
const PAY_HEADER = "x-payment";
|
|
11
11
|
export class PaydluxAgent {
|
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
|
-
/**
|
|
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
|
@@ -11,11 +11,66 @@
|
|
|
11
11
|
// - the approval is a real ledger signature, not a database flag
|
|
12
12
|
import { createServer } from "node:http";
|
|
13
13
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
-
import { Keypair } from "stellar-sdk";
|
|
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
|
-
/**
|
|
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/gateway.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
-
import { Transaction } from "stellar-sdk";
|
|
2
|
+
import { Transaction } from "@stellar/stellar-sdk";
|
|
3
3
|
import { StellarVault, StellarTxError, type Amount, type Mandate, type Network } from "./vault.ts";
|
|
4
4
|
import { ApprovalDesk, type DeskStore } from "./desk.ts";
|
|
5
5
|
export declare const PAY_HEADER = "x-payment";
|
|
@@ -58,7 +58,7 @@ export declare class Gateway {
|
|
|
58
58
|
payTo: string;
|
|
59
59
|
token: {
|
|
60
60
|
code: string;
|
|
61
|
-
issuer: string;
|
|
61
|
+
issuer: string | undefined;
|
|
62
62
|
};
|
|
63
63
|
vault: string;
|
|
64
64
|
mode: "approval" | "preauth";
|
|
@@ -72,7 +72,7 @@ export declare class Gateway {
|
|
|
72
72
|
*/
|
|
73
73
|
verify(uri: string, proof: PaymentProof): Promise<{
|
|
74
74
|
route: Route;
|
|
75
|
-
tx: Transaction
|
|
75
|
+
tx: Transaction;
|
|
76
76
|
hash: string;
|
|
77
77
|
}>;
|
|
78
78
|
handler(getContent: (uri: string) => string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
package/dist/gateway.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Either way the agent's key alone can never move money: threshold is 2.
|
|
15
15
|
import { createServer } from "node:http";
|
|
16
|
-
import { Keypair, StrKey, Transaction } from "stellar-sdk";
|
|
17
|
-
import { StellarTxError, passphrase } from "./vault.js";
|
|
16
|
+
import { Keypair, StrKey, Transaction } from "@stellar/stellar-sdk";
|
|
17
|
+
import { StellarTxError, hex, passphrase } from "./vault.js";
|
|
18
18
|
import { ApprovalDesk } from "./desk.js";
|
|
19
19
|
export const PAY_HEADER = "x-payment";
|
|
20
20
|
export class Gateway {
|
|
@@ -123,7 +123,7 @@ export class Gateway {
|
|
|
123
123
|
|| op.asset?.getIssuer?.() !== this.cfg.vault.token.getIssuer()) {
|
|
124
124
|
throw new Error("envelope pays in a different token");
|
|
125
125
|
}
|
|
126
|
-
const hash = tx.hash()
|
|
126
|
+
const hash = hex(tx.hash());
|
|
127
127
|
if (hash !== proof.mandateHash)
|
|
128
128
|
throw new Error("envelope hash does not match the declared mandate");
|
|
129
129
|
// The vault stores the mandate as a strkey "T…" signer (base32 of the hash),
|
|
@@ -131,7 +131,7 @@ export class Gateway {
|
|
|
131
131
|
const live = await this.cfg.vault.signerSummary();
|
|
132
132
|
const registered = live.signers
|
|
133
133
|
.filter((s) => s.type === "preauth-mandate")
|
|
134
|
-
.map((s) => StrKey.decodePreAuthTx(s.key)
|
|
134
|
+
.map((s) => hex(StrKey.decodePreAuthTx(s.key)));
|
|
135
135
|
if (!registered.includes(hash)) {
|
|
136
136
|
throw new Error("mandate is not registered on the vault (expired or revoked)");
|
|
137
137
|
}
|
|
@@ -212,7 +212,7 @@ export class Gateway {
|
|
|
212
212
|
const agentSig = tx.signatures?.[0];
|
|
213
213
|
if (!agentSig)
|
|
214
214
|
throw new Error("envelope carries no agent signature");
|
|
215
|
-
const envHash = tx.hash()
|
|
215
|
+
const envHash = hex(tx.hash());
|
|
216
216
|
if (this.settled.has(envHash))
|
|
217
217
|
throw new Error("mandate already redeemed");
|
|
218
218
|
const applied = await this.cfg.vault.txExists(envHash);
|
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";
|
package/dist/phone.d.ts
ADDED
|
@@ -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(
|
|
42
|
-
|
|
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
|
-
|
|
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
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { Asset, Horizon, Keypair, Networks } from "stellar-sdk";
|
|
1
|
+
import { Asset, Horizon, Keypair, Networks } from "@stellar/stellar-sdk";
|
|
2
2
|
export type Network = "testnet" | "public";
|
|
3
|
+
/** stellar-sdk v17 hands back Uint8Array where v13 gave Buffer. One helper, one shape. */
|
|
4
|
+
export declare function hex(b: Uint8Array | string): string;
|
|
3
5
|
/**
|
|
4
6
|
* Circle's own USDC issuer on Stellar. Not a wrapped token, not a bridge:
|
|
5
7
|
* native, and Circle is the issuer. Anyone can put "USDC" on a token name —
|
|
6
8
|
* only these two accounts are real money. Never accept any other issuer.
|
|
7
9
|
*/
|
|
8
10
|
export declare const USDC_ISSUER: Record<Network, string>;
|
|
9
|
-
/**
|
|
11
|
+
/** Turn Horizon's result_xdr into a readable code, e.g. "BAD_AUTH". */
|
|
12
|
+
export declare function txResultName(resultXdr: string): string | undefined;
|
|
10
13
|
export declare class StellarTxError extends Error {
|
|
11
14
|
code: string;
|
|
12
15
|
constructor(code: string, cause?: unknown);
|
package/dist/vault.js
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
// amount, the payee, the window, or run it twice — the ledger says no.
|
|
15
15
|
//
|
|
16
16
|
// Verified on testnet by stellar/probe-envelope.ts before this file existed.
|
|
17
|
-
import { Account, Asset, Horizon, Keypair, Memo, Networks, Operation, StrKey, Transaction, TransactionBuilder, } from "stellar-sdk";
|
|
17
|
+
import { Account, Asset, Horizon, Keypair, Memo, Networks, Operation, StrKey, Transaction, TransactionBuilder, } from "@stellar/stellar-sdk";
|
|
18
|
+
/** stellar-sdk v17 hands back Uint8Array where v13 gave Buffer. One helper, one shape. */
|
|
19
|
+
export function hex(b) {
|
|
20
|
+
return typeof b === "string" ? b : Buffer.from(b).toString("hex");
|
|
21
|
+
}
|
|
18
22
|
/**
|
|
19
23
|
* Circle's own USDC issuer on Stellar. Not a wrapped token, not a bridge:
|
|
20
24
|
* native, and Circle is the issuer. Anyone can put "USDC" on a token name —
|
|
@@ -25,6 +29,31 @@ export const USDC_ISSUER = {
|
|
|
25
29
|
testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
|
|
26
30
|
};
|
|
27
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
|
+
}
|
|
28
57
|
export class StellarTxError extends Error {
|
|
29
58
|
code;
|
|
30
59
|
constructor(code, cause) {
|
|
@@ -181,7 +210,7 @@ export class StellarVault {
|
|
|
181
210
|
if (spec.memo)
|
|
182
211
|
eb.addMemo(Memo.text(spec.memo));
|
|
183
212
|
const envelope = eb.build();
|
|
184
|
-
const hash = envelope.hash()
|
|
213
|
+
const hash = hex(envelope.hash());
|
|
185
214
|
// register the hash as a weight-1 signer. Two signatures now exist that
|
|
186
215
|
// reach threshold 2: (agent + this hash) and (principal + approver).
|
|
187
216
|
// setOptions needs med threshold 2, so registering a mandate always takes
|
|
@@ -289,7 +318,9 @@ async function sub(h, _pass, tx, ...signers) {
|
|
|
289
318
|
}
|
|
290
319
|
catch { /* shape differs */ }
|
|
291
320
|
codes ??= e?.response?.data?.extras?.result_codes?.results;
|
|
292
|
-
const
|
|
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);
|
|
293
324
|
throw new StellarTxError(detail, e);
|
|
294
325
|
}
|
|
295
326
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"access": "public"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"stellar-sdk": "^
|
|
57
|
+
"@stellar/stellar-sdk": "^17.4.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^26.6.6",
|