@paydlux/agent 0.1.20 → 0.1.22
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 +68 -0
- package/dist/agent.d.ts +14 -0
- package/dist/agent.js +52 -1
- package/dist/cli.js +162 -8
- package/dist/desk.d.ts +59 -0
- package/dist/desk.js +192 -1
- package/dist/escrow.d.ts +154 -0
- package/dist/escrow.js +325 -0
- package/dist/gateway.d.ts +101 -3
- package/dist/gateway.js +170 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/package.json +3 -2
- package/phone-app/app.js +26 -6
- package/phone-app/index.html +1 -1
- package/phone-app/xdr.js +23 -4
package/dist/gateway.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Paydlux gateway on Stellar — an HTTP resource server that answers 402 and
|
|
2
2
|
// only serves the body once payment has actually landed on the ledger.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// Three modes per route:
|
|
5
5
|
//
|
|
6
6
|
// preauth the human already registered a mandate envelope for this seller
|
|
7
7
|
// and amount. The gateway hands it over, the agent signs, submits,
|
|
@@ -11,12 +11,19 @@
|
|
|
11
11
|
// holds it at the approval desk, and a person approves or denies.
|
|
12
12
|
// On approval the vault releases with principal + approver.
|
|
13
13
|
//
|
|
14
|
+
// escrow the buyer locks the money in a claimable balance the seller
|
|
15
|
+
// cannot touch until the release timer passes. Delivery has to be
|
|
16
|
+
// proven before that timer, and releasing still needs both human
|
|
17
|
+
// keys. Until then the buyer can always take it back.
|
|
18
|
+
//
|
|
14
19
|
// Either way the agent's key alone can never move money: threshold is 2.
|
|
15
20
|
import { createServer } from "node:http";
|
|
16
21
|
import { createHash } from "node:crypto";
|
|
17
22
|
import { Keypair, StrKey, Transaction } from "@stellar/stellar-sdk";
|
|
18
23
|
import { StellarTxError, hex, passphrase } from "./vault.js";
|
|
19
24
|
import { ApprovalDesk } from "./desk.js";
|
|
25
|
+
import { claimEscrow, escrowOutcome, prepareEscrowRelease, readEscrow, verifyEscrow } from "./escrow.js";
|
|
26
|
+
import { submitApproved } from "./phone.js";
|
|
20
27
|
export const PAY_HEADER = "x-payment";
|
|
21
28
|
export class Gateway {
|
|
22
29
|
routes = new Map();
|
|
@@ -38,6 +45,7 @@ export class Gateway {
|
|
|
38
45
|
port: cfg.deskPort ?? 4022,
|
|
39
46
|
portalKey: cfg.portalKey,
|
|
40
47
|
split: cfg.split,
|
|
48
|
+
agentSeed: cfg.agentSeed,
|
|
41
49
|
publicHost: cfg.publicHost,
|
|
42
50
|
decideWithinSeconds: cfg.decideWithinSeconds,
|
|
43
51
|
policy: cfg.policy,
|
|
@@ -48,6 +56,87 @@ export class Gateway {
|
|
|
48
56
|
const s = await this.cfg.vault.signerSummary();
|
|
49
57
|
return s.signers.filter((x) => x.type === "preauth-mandate");
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Escrow orders this gateway has issued, keyed by order id.
|
|
61
|
+
*
|
|
62
|
+
* The order id is the ONLY link between a pile of money on the ledger and a
|
|
63
|
+
* piece of content behind a route: Horizon does not put a memo on the balance
|
|
64
|
+
* record, so matching money to a deal means reading the memo off the creating
|
|
65
|
+
* transaction and comparing it to an order we recognise. An escrow whose memo
|
|
66
|
+
* matches nothing here is money locked for a deal this seller never quoted, so
|
|
67
|
+
* it buys nothing.
|
|
68
|
+
*/
|
|
69
|
+
orders = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* A stable order id for one request. Deterministic on purpose: a buyer who
|
|
72
|
+
* retries while the escrow settles must land on the same order, and a seller
|
|
73
|
+
* who restarts must be able to recognise the memo on money already locked.
|
|
74
|
+
* It is not a secret and it is not a capability — holding an id buys nothing
|
|
75
|
+
* without the matching escrow on the ledger.
|
|
76
|
+
*/
|
|
77
|
+
orderId(uri, price, body) {
|
|
78
|
+
return "ord_" + createHash("sha256")
|
|
79
|
+
.update(`${this.cfg.vault.address}|${uri}|${price}|${body}`).digest("hex").slice(0, 16);
|
|
80
|
+
}
|
|
81
|
+
/** Escrow orders, oldest first, with whatever the ledger says about each. */
|
|
82
|
+
async escrowOrders(opts = {}) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const o of this.orders.values()) {
|
|
85
|
+
const e = o.escrowId ? await readEscrow(this.cfg.vault.network, o.escrowId) : null;
|
|
86
|
+
const outcome = o.escrowId ? await escrowOutcome(this.cfg.vault.network, o.escrowId) : { settled: false };
|
|
87
|
+
const now = Math.floor(Date.now() / 1000);
|
|
88
|
+
const state = e
|
|
89
|
+
? (now >= e.releaseAt ? "releasable" : "locked")
|
|
90
|
+
: outcome.settled ? "released" : o.escrowId ? "gone" : "awaiting_escrow";
|
|
91
|
+
if (state === "released" && !opts.includeSettled)
|
|
92
|
+
continue;
|
|
93
|
+
// A releasable escrow goes to the desk automatically, so the phone sees
|
|
94
|
+
// the release without anyone re-running the CLI. addEscrow is idempotent
|
|
95
|
+
// per balance id, so this is safe to call from a read path.
|
|
96
|
+
if (state === "releasable" && e) {
|
|
97
|
+
this.desk.addEscrow({
|
|
98
|
+
escrowId: o.escrowId,
|
|
99
|
+
order: o.id,
|
|
100
|
+
amount: e.amount,
|
|
101
|
+
asset: e.asset === "native" ? "XLM" : e.asset.split(":")[0],
|
|
102
|
+
buyer: e.buyer,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
out.push({ ...o, state, escrow: e, outcome });
|
|
106
|
+
}
|
|
107
|
+
return out.sort((a, b) => (a.releaseAt ?? 0) - (b.releaseAt ?? 0));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Take the money the buyer locked. Two keys, always — the vault's threshold
|
|
111
|
+
* is 2, so a stolen agent key cannot empty an escrow on its own.
|
|
112
|
+
*/
|
|
113
|
+
async releaseEscrow(orderId, keys) {
|
|
114
|
+
const o = this.orders.get(orderId);
|
|
115
|
+
if (!o?.escrowId)
|
|
116
|
+
throw new Error(`no escrow locked for order ${orderId}`);
|
|
117
|
+
const out = await claimEscrow(this.cfg.vault.network, o.escrowId, this.cfg.vault.address, keys);
|
|
118
|
+
this.settled.add("escrow:" + o.escrowId);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The split-key release: build the claim, sign it with the agent key, and
|
|
123
|
+
* hand it to the phone. This process cannot finish it — that is the point.
|
|
124
|
+
*/
|
|
125
|
+
async escrowReleaseChallenge(orderId, agent) {
|
|
126
|
+
const o = this.orders.get(orderId);
|
|
127
|
+
if (!o?.escrowId)
|
|
128
|
+
throw new Error(`no escrow locked for order ${orderId}`);
|
|
129
|
+
return prepareEscrowRelease(this.cfg.vault.network, o.escrowId, this.cfg.vault.address, agent);
|
|
130
|
+
}
|
|
131
|
+
/** Take the phone's signature back and settle the escrow on the ledger. */
|
|
132
|
+
async submitEscrowRelease(signedXdr, orderId) {
|
|
133
|
+
const o = this.orders.get(orderId);
|
|
134
|
+
if (!o?.escrowId)
|
|
135
|
+
throw new Error(`no escrow locked for order ${orderId}`);
|
|
136
|
+
const out = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
|
|
137
|
+
this.settled.add("escrow:" + o.escrowId);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
51
140
|
/** Register a pre-authorised mandate for a route (needs both human keys). */
|
|
52
141
|
async authorise(uri, spec, principal, approver) {
|
|
53
142
|
const r = this.routes.get(uri);
|
|
@@ -101,8 +190,8 @@ export class Gateway {
|
|
|
101
190
|
agentKeys;
|
|
102
191
|
requirements(uri) {
|
|
103
192
|
const r = this.routes.get(uri);
|
|
104
|
-
|
|
105
|
-
scheme: "stellar-exact",
|
|
193
|
+
const base = {
|
|
194
|
+
scheme: r.mode === "escrow" ? "stellar-escrow" : "stellar-exact",
|
|
106
195
|
network: `stellar:${this.cfg.network}`,
|
|
107
196
|
maxAmountRequired: r.price,
|
|
108
197
|
resource: uri,
|
|
@@ -113,6 +202,21 @@ export class Gateway {
|
|
|
113
202
|
maxTimeoutSeconds: r.holdSeconds ?? 3600,
|
|
114
203
|
version: "1",
|
|
115
204
|
};
|
|
205
|
+
if (r.mode !== "escrow")
|
|
206
|
+
return base;
|
|
207
|
+
// An escrow route tells the buyer how to lock the money, not just what to
|
|
208
|
+
// pay. Without the release window in the 402 the buyer cannot compute a
|
|
209
|
+
// valid timer, and the seller has no deadline to prove delivery against.
|
|
210
|
+
return {
|
|
211
|
+
...base,
|
|
212
|
+
escrow: {
|
|
213
|
+
vault: this.cfg.vault.address,
|
|
214
|
+
releaseSeconds: r.releaseSeconds ?? 3600,
|
|
215
|
+
// The memo on the creating transaction must be this order id, or the
|
|
216
|
+
// money cannot be matched to a deal and buys nothing.
|
|
217
|
+
memoMustBe: "the order id returned in this response",
|
|
218
|
+
},
|
|
219
|
+
};
|
|
116
220
|
}
|
|
117
221
|
/**
|
|
118
222
|
* Check a proof against the live request before spending anything.
|
|
@@ -193,8 +297,12 @@ export class Gateway {
|
|
|
193
297
|
const raw = req.headers[PAY_HEADER];
|
|
194
298
|
if (!raw) {
|
|
195
299
|
const reqs = this.requirements(uri);
|
|
196
|
-
|
|
197
|
-
|
|
300
|
+
// An escrow buyer has to know the order id BEFORE locking the money,
|
|
301
|
+
// because the memo on the creating transaction is the only thing that
|
|
302
|
+
// ties that pile of money to this deal. Hand it out in the 402.
|
|
303
|
+
const order = r.mode === "escrow" ? { order: this.orderId(uri, r.price, body) } : {};
|
|
304
|
+
res.writeHead(402, { [PAY_HEADER]: JSON.stringify({ ...reqs, ...order }) });
|
|
305
|
+
res.end(JSON.stringify({ error: "payment_required", accepts: reqs, ...order }));
|
|
198
306
|
return;
|
|
199
307
|
}
|
|
200
308
|
let proof;
|
|
@@ -205,8 +313,63 @@ export class Gateway {
|
|
|
205
313
|
res.writeHead(400).end("bad payment header");
|
|
206
314
|
return;
|
|
207
315
|
}
|
|
208
|
-
if (!proof.intent && !proof.envelope) {
|
|
209
|
-
res.writeHead(400).end("payment header needs an envelope or an
|
|
316
|
+
if (!proof.intent && !proof.envelope && !proof.escrow) {
|
|
317
|
+
res.writeHead(400).end("payment header needs an envelope, an intent, or an escrow id");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
// escrow mode: the buyer locked the money in a claimable balance. The
|
|
321
|
+
// seller cannot claim it yet — that is the whole deal. Serving the body
|
|
322
|
+
// now means the buyer still holds the refund right, so the seller's
|
|
323
|
+
// incentive is to deliver first and release later.
|
|
324
|
+
if (r.mode === "escrow") {
|
|
325
|
+
if (!proof.escrow) {
|
|
326
|
+
res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
|
|
327
|
+
res.end(JSON.stringify({ error: "escrow_required", reason: "this route is sold against escrow: lock the payment in a claimable balance first", escrow: this.requirements(uri).escrow }));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const id = this.orderId(uri, r.price, body);
|
|
331
|
+
const releaseSeconds = r.releaseSeconds ?? 3600;
|
|
332
|
+
const check = await verifyEscrow(this.cfg.vault.network, proof.escrow, {
|
|
333
|
+
orderId: id,
|
|
334
|
+
asset: this.cfg.vault.token,
|
|
335
|
+
amount: r.price,
|
|
336
|
+
vault: this.cfg.vault.address,
|
|
337
|
+
releaseAt: 0, // not compared; the window checks below do the work
|
|
338
|
+
}, { minReleaseSeconds: 1, maxReleaseSeconds: releaseSeconds + 300 });
|
|
339
|
+
if (!check.ok || !check.escrow) {
|
|
340
|
+
res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
|
|
341
|
+
res.end(JSON.stringify({ error: "escrow_invalid", order: id, reason: check.reason }));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const prior = this.orders.get(id);
|
|
345
|
+
this.orders.set(id, {
|
|
346
|
+
id, uri, price: r.price, at: prior?.at ?? Date.now(),
|
|
347
|
+
escrowId: proof.escrow, releaseAt: check.escrow.releaseAt,
|
|
348
|
+
served: (prior?.served ?? 0) + 1,
|
|
349
|
+
});
|
|
350
|
+
const content = getContent(uri);
|
|
351
|
+
res.writeHead(200, {
|
|
352
|
+
"content-type": "application/json",
|
|
353
|
+
"x-payment-response": JSON.stringify({
|
|
354
|
+
escrow: proof.escrow, order: id,
|
|
355
|
+
releaseAt: check.escrow.releaseAt,
|
|
356
|
+
lockTx: check.escrow.txHash,
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
res.end(JSON.stringify({
|
|
360
|
+
data: content,
|
|
361
|
+
order: id,
|
|
362
|
+
escrow: {
|
|
363
|
+
id: proof.escrow,
|
|
364
|
+
amount: check.escrow.amount,
|
|
365
|
+
releaseAt: check.escrow.releaseAt,
|
|
366
|
+
// The buyer's own refund right, spelled out so a client can act on
|
|
367
|
+
// it without reading a spec: claim the balance back before this
|
|
368
|
+
// second, no seller consent needed.
|
|
369
|
+
refundUntil: check.escrow.releaseAt,
|
|
370
|
+
lockTx: check.escrow.txHash,
|
|
371
|
+
},
|
|
372
|
+
}));
|
|
210
373
|
return;
|
|
211
374
|
}
|
|
212
375
|
// approval mode: the agent sends a signed intent, a human decides
|
package/dist/index.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ export type { Policy, SpendEvent, Verdict } from "./policy.ts";
|
|
|
12
12
|
export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.ts";
|
|
13
13
|
export { txResultName } from "./vault.ts";
|
|
14
14
|
export type { ApprovalChallenge } from "./phone.ts";
|
|
15
|
+
export { createEscrow, verifyEscrow, readEscrow, claimEscrow, refundEscrow, escrowOutcome, pendingEscrows, escrowBalanceId, assetToHorizonString, assetFromHorizonString, } from "./escrow.ts";
|
|
16
|
+
export type { EscrowTerms, Escrow, EscrowCheck } from "./escrow.ts";
|
package/dist/index.js
CHANGED
|
@@ -10,3 +10,7 @@ export { PolicyEngine, DEFAULT_POLICY } from "./policy.js";
|
|
|
10
10
|
// Split signing: the human's key lives on a phone, not in the seller's process.
|
|
11
11
|
export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.js";
|
|
12
12
|
export { txResultName } from "./vault.js";
|
|
13
|
+
// Escrow: native claimable balances, not a contract. The buyer locks the money,
|
|
14
|
+
// the seller cannot touch it until the release timer, and the buyer can always
|
|
15
|
+
// take it back before then.
|
|
16
|
+
export { createEscrow, verifyEscrow, readEscrow, claimEscrow, refundEscrow, escrowOutcome, pendingEscrows, escrowBalanceId, assetToHorizonString, assetFromHorizonString, } from "./escrow.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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",
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
"site:qr": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg",
|
|
65
65
|
"address:check": "tsx stellar/check-address.ts",
|
|
66
66
|
"test:address": "tsx stellar/address-check.ts",
|
|
67
|
-
"site:deploy": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg && wrangler deploy"
|
|
67
|
+
"site:deploy": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg && wrangler deploy",
|
|
68
|
+
"test:escrow": "tsx stellar/escrow-check.ts && tsx stellar/escrow-gateway-check.ts && tsx stellar/escrow-phone-check.ts"
|
|
68
69
|
},
|
|
69
70
|
"keywords": [
|
|
70
71
|
"x402",
|
package/phone-app/app.js
CHANGED
|
@@ -371,8 +371,16 @@ async function openReview(id) {
|
|
|
371
371
|
const rec = await api("/challenge/" + encodeURIComponent(id));
|
|
372
372
|
const env = X.parseEnvelope(rec.xdr);
|
|
373
373
|
const op = env.tx.ops[0];
|
|
374
|
-
|
|
375
|
-
|
|
374
|
+
// Two shapes are signable: a single payment, and a single escrow claim
|
|
375
|
+
// (the vault taking money a buyer locked in a claimable balance). Anything
|
|
376
|
+
// else is refused, and the escrow branch verifies the claim targets the
|
|
377
|
+
// balance id the desk says this approval is for.
|
|
378
|
+
const isClaim = op.type === X.OP_CLAIM_CLAIMABLE_BALANCE;
|
|
379
|
+
if (env.tx.ops.length !== 1 || (op.type !== X.OP_PAYMENT && !isClaim)) {
|
|
380
|
+
throw new Error("this transaction is not a single payment or escrow claim \u2014 refusing to sign it");
|
|
381
|
+
}
|
|
382
|
+
if (isClaim && rec.escrowId && op.balanceId !== rec.escrowId) {
|
|
383
|
+
throw new Error("the claim targets a different balance than this approval is for \u2014 refusing");
|
|
376
384
|
}
|
|
377
385
|
const { hash } = await X.transactionHash(rec.xdr, X.NETWORK_ID[rec.network] || rec.network);
|
|
378
386
|
if (rec.hash && hash !== rec.hash) {
|
|
@@ -391,10 +399,14 @@ async function openReview(id) {
|
|
|
391
399
|
}
|
|
392
400
|
current = {
|
|
393
401
|
id, xdr: rec.xdr, hash,
|
|
394
|
-
amount:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
402
|
+
// A claim op carries no amount: the money is whatever the ledger says the
|
|
403
|
+
// balance holds. The desk read that number when it queued the release,
|
|
404
|
+
// so the human sees it from the desk, and the balance id next to it is
|
|
405
|
+
// what ties the number to the order.
|
|
406
|
+
amount: isClaim ? String(rec.amount ?? "") : X.formatAmount(op.amount),
|
|
407
|
+
token: isClaim ? String(rec.asset ?? "") : (op.asset.code || "XLM"),
|
|
408
|
+
issuer: isClaim ? "" : (op.asset.issuer || ""),
|
|
409
|
+
payee: isClaim ? op.balanceId : X.encodeAccountEd25519(op.destination.pub),
|
|
398
410
|
vault: X.encodeAccountEd25519(env.tx.source.pub),
|
|
399
411
|
// Read the memo out of the envelope, not out of the server's summary:
|
|
400
412
|
// the envelope is what is being signed, so it is the only source that
|
|
@@ -417,6 +429,10 @@ async function openReview(id) {
|
|
|
417
429
|
$("amount").append(num, tk);
|
|
418
430
|
set("wantsBig", current.wants);
|
|
419
431
|
set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
|
|
432
|
+
// An escrow claim has no payee: the money goes to the vault itself. What
|
|
433
|
+
// the human verifies instead is the balance id — the ledger address of the
|
|
434
|
+
// locked money — so the row is labelled for that.
|
|
435
|
+
$("payeeLabel").textContent = isClaim ? "Escrow balance" : "Payee";
|
|
420
436
|
set("payee", current.payee);
|
|
421
437
|
set("vault", current.vault);
|
|
422
438
|
set("wants", current.wants);
|
|
@@ -432,6 +448,10 @@ async function openReview(id) {
|
|
|
432
448
|
$("netChip").style.color = current.network === "public" ? "var(--ok)" : "var(--warn)";
|
|
433
449
|
err("signErr", "");
|
|
434
450
|
resetHold();
|
|
451
|
+
// The hold button says what it does. Releasing an escrow is not paying
|
|
452
|
+
// anyone: it is the vault taking money a buyer already locked. After
|
|
453
|
+
// resetHold, which resets the label to the payment wording.
|
|
454
|
+
if (isClaim) set("holdLbl", "Hold to release \u0026 sign");
|
|
435
455
|
show("review", true);
|
|
436
456
|
show("queue", false);
|
|
437
457
|
$("review").scrollIntoView({ behavior: "smooth", block: "start" });
|
package/phone-app/index.html
CHANGED
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
<div class="hint" id="wantsBig"></div>
|
|
236
236
|
<dl class="kv" style="margin-top:16px">
|
|
237
237
|
<dt>Token</dt><dd id="token"></dd>
|
|
238
|
-
<dt>Payee</dt><dd id="payee"></dd>
|
|
238
|
+
<dt id="payeeLabel">Payee</dt><dd id="payee"></dd>
|
|
239
239
|
<dt>From</dt><dd id="vault"></dd>
|
|
240
240
|
<dt>Wants</dt><dd id="wants"></dd>
|
|
241
241
|
<dt>Reference</dt><dd id="memo"></dd>
|
package/phone-app/xdr.js
CHANGED
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
const SC_ADDRESS = 4; // soroban account: we refuse it
|
|
77
77
|
const ASSET_NATIVE = 0, ASSET_ALPHANUM4 = 1, ASSET_ALPHANUM12 = 2;
|
|
78
78
|
const OP_PAYMENT = 1;
|
|
79
|
+
// ClaimClaimableBalance is the escrow release: the vault taking the money the
|
|
80
|
+
// buyer locked. The phone must read it as well as payments, or a seller on a
|
|
81
|
+
// --split vault can never release an escrow from the phone.
|
|
82
|
+
const OP_CLAIM_CLAIMABLE_BALANCE = 15;
|
|
79
83
|
const PRECOND_NONE = 0, PRECOND_TIME = 1, PRECOND_V2 = 2;
|
|
80
84
|
const MEMO_NONE = 0, MEMO_TEXT = 1, MEMO_ID = 2, MEMO_HASH = 3, MEMO_RETURN = 4;
|
|
81
85
|
const STROOPS = 10000000;
|
|
@@ -318,14 +322,29 @@
|
|
|
318
322
|
const hasSource = r.u32();
|
|
319
323
|
const source = hasSource ? readMuxedAccount(r) : null;
|
|
320
324
|
const type = r.u32(); // OperationBody union switch
|
|
325
|
+
if (type === OP_CLAIM_CLAIMABLE_BALANCE) {
|
|
326
|
+
// ClaimableBalanceID is an union: switch word, then 32 raw bytes. Only
|
|
327
|
+
// the balance-id form (switch 0) exists today; anything else is refused
|
|
328
|
+
// rather than skipped, because skipping bytes misreads the rest.
|
|
329
|
+
const which = r.u32();
|
|
330
|
+
if (which !== 0) throw new Error("claimable balance id form " + which + " is not supported; refusing to sign");
|
|
331
|
+
// The balance id AS THE LEDGER AND THE SDK NAME IT: the 4-byte union
|
|
332
|
+
// switch ("00000000") followed by the 32-byte hash. Returning only the
|
|
333
|
+
// hash would disagree with every id the desk and Horizon use, and the
|
|
334
|
+
// desk's "this claim targets THIS escrow" check would never match.
|
|
335
|
+
const balanceId = "00000000" + hex(r.fixed(32));
|
|
336
|
+
// The claim op carries NO amount: the money is whatever the ledger says
|
|
337
|
+
// that balance holds. The human verifies the balance id instead.
|
|
338
|
+
return { type, source, balanceId, destination: null, asset: null, amount: null };
|
|
339
|
+
}
|
|
321
340
|
if (type !== OP_PAYMENT) {
|
|
322
|
-
throw new Error("operation type " + type + " is not a payment; this app only signs
|
|
341
|
+
throw new Error("operation type " + type + " is not a payment or an escrow claim; this app only signs those");
|
|
323
342
|
}
|
|
324
343
|
const destination = readMuxedAccount(r);
|
|
325
344
|
const asset = readAsset(r);
|
|
326
345
|
const amount = r.i64();
|
|
327
346
|
if (amount < 0n) throw new Error("payment amount is negative; refusing to sign");
|
|
328
|
-
return { type, source, destination, asset, amount: amount.toString() };
|
|
347
|
+
return { type, source, destination, asset, amount: amount.toString(), balanceId: null };
|
|
329
348
|
}
|
|
330
349
|
|
|
331
350
|
function readTransaction(r) {
|
|
@@ -336,7 +355,7 @@
|
|
|
336
355
|
const memo = readMemo(r);
|
|
337
356
|
const numOps = r.u32();
|
|
338
357
|
if (numOps !== 1) {
|
|
339
|
-
throw new Error("this app signs
|
|
358
|
+
throw new Error("this app signs one operation at a time, and this one has "
|
|
340
359
|
+ numOps + " operations");
|
|
341
360
|
}
|
|
342
361
|
const ops = [];
|
|
@@ -471,6 +490,6 @@
|
|
|
471
490
|
encodeAccountEd25519, decodeAccountEd25519,
|
|
472
491
|
decodeSecretSeed, encodeSecretSeed, importSecretSeed, importSecretSeedPair,
|
|
473
492
|
NETWORK_ID, crc16Xmodem,
|
|
474
|
-
ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
|
|
493
|
+
ENVELOPE_TYPE_TX, OP_PAYMENT, OP_CLAIM_CLAIMABLE_BALANCE, STROOPS,
|
|
475
494
|
};
|
|
476
495
|
})(typeof globalThis !== "undefined" ? globalThis : this);
|