@paydlux/agent 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +404 -23
- package/dist/desk.js +4 -0
- package/dist/donate.d.ts +48 -0
- package/dist/donate.js +232 -0
- package/dist/phone.d.ts +7 -0
- package/dist/phone.js +7 -1
- package/dist/qr.d.ts +12 -0
- package/dist/qr.js +52 -0
- package/dist/receive.d.ts +147 -0
- package/dist/receive.js +348 -0
- package/dist/showcase.d.ts +54 -0
- package/dist/showcase.js +385 -0
- package/dist/tunnel.d.ts +20 -0
- package/dist/tunnel.js +40 -2
- package/dist/vault.d.ts +42 -2
- package/dist/vault.js +109 -12
- package/package.json +20 -3
- package/phone-app/app.js +12 -1
- package/phone-app/index.html +1 -0
package/dist/vault.js
CHANGED
|
@@ -74,6 +74,57 @@ const PASSPHRASE = {
|
|
|
74
74
|
const FRIENDBOT = "https://friendbot.stellar.org";
|
|
75
75
|
/** The network passphrase for a network name (needed to hash transactions). */
|
|
76
76
|
export function passphrase(network) { return PASSPHRASE[network]; }
|
|
77
|
+
/**
|
|
78
|
+
* Does a Horizon balance/payment record name this exact asset?
|
|
79
|
+
*
|
|
80
|
+
* The old code hard-coded "not native" in two places, which quietly made a
|
|
81
|
+
* native-XLM vault blind to its own money: balance() reported 0 and paidToday()
|
|
82
|
+
* reported 0 spent, so the daily budget never moved no matter how much XLM left
|
|
83
|
+
* the vault. Asset identity has to be ONE comparison that handles both cases,
|
|
84
|
+
* because a budget that reads as unspent is worse than no budget at all.
|
|
85
|
+
*
|
|
86
|
+
* Exported as a pure function so the rule is testable without funding an
|
|
87
|
+
* account: this decides whether money counts against a spending mandate.
|
|
88
|
+
*/
|
|
89
|
+
export function assetMatches(token, rec) {
|
|
90
|
+
// stellar-base has no Native subclass: a native asset is an Asset whose
|
|
91
|
+
// getAssetType() is "native" and whose getIssuer() is undefined. Comparing
|
|
92
|
+
// constructors would silently treat XLM as a token with no issuer.
|
|
93
|
+
if (token.getAssetType() === "native") {
|
|
94
|
+
// Native XLM records carry asset_type "native" and no code/issuer at all.
|
|
95
|
+
return rec?.asset_type === "native";
|
|
96
|
+
}
|
|
97
|
+
return rec?.asset_type !== "native"
|
|
98
|
+
&& rec?.asset_type !== "liquidity_pool_shares"
|
|
99
|
+
&& rec?.asset_code === token.getCode()
|
|
100
|
+
&& rec?.asset_issuer === token.getIssuer();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Turn a token name from a config file into an Asset.
|
|
104
|
+
*
|
|
105
|
+
* "XLM" is not a token. It is the ledger's own currency, and it behaves
|
|
106
|
+
* differently in one way that breaks code written for tokens: an account does
|
|
107
|
+
* not need a trustline to receive it. Passing Asset("XLM", someIssuer) would
|
|
108
|
+
* build a *fake* asset that merely happens to be called XLM — and the ledger
|
|
109
|
+
* would then demand a trustline for an asset that cannot have one, while
|
|
110
|
+
* assetMatches() would refuse to count the real XLM that actually arrived.
|
|
111
|
+
*
|
|
112
|
+
* So the name is resolved here, in one place, and every caller gets either a
|
|
113
|
+
* real native asset or a real code+issuer pair.
|
|
114
|
+
*/
|
|
115
|
+
export function parseToken(code, issuer) {
|
|
116
|
+
const c = (code ?? "").toUpperCase();
|
|
117
|
+
if (c === "XLM" || c === "NATIVE") {
|
|
118
|
+
if (issuer)
|
|
119
|
+
throw new Error(`XLM has no issuer. Do not pass one (got ${issuer}).`);
|
|
120
|
+
return Asset.native();
|
|
121
|
+
}
|
|
122
|
+
if (!c)
|
|
123
|
+
throw new Error("token code is empty");
|
|
124
|
+
if (!issuer)
|
|
125
|
+
throw new Error(`token ${c} needs an issuer account`);
|
|
126
|
+
return new Asset(c, issuer);
|
|
127
|
+
}
|
|
77
128
|
export class StellarVault {
|
|
78
129
|
horizon;
|
|
79
130
|
network;
|
|
@@ -123,14 +174,21 @@ export class StellarVault {
|
|
|
123
174
|
// The trustline has to ride in this same transaction: once masterWeight is
|
|
124
175
|
// 0 the vault seed cannot sign anything on its own, so a later changeTrust
|
|
125
176
|
// would need two human signatures before the vault can even hold tokens.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
177
|
+
//
|
|
178
|
+
// XLM gets no trustline at all. It is the ledger's own currency, so
|
|
179
|
+
// changeTrust on it is not merely unnecessary — the ledger rejects the
|
|
180
|
+
// operation, which would abort this whole transaction and leave a vault
|
|
181
|
+
// that exists but has no signers and no thresholds.
|
|
182
|
+
const token = parseToken(opts.tokenCode, opts.tokenIssuer ?? (opts.tokenCode.toUpperCase() === "XLM" ? undefined : issuer.publicKey()));
|
|
183
|
+
const setup = (await tb(h, vault.publicKey(), undefined, pass))
|
|
129
184
|
.addOperation(Operation.setOptions({ signer: { ed25519PublicKey: principal.publicKey(), weight: 1 } }))
|
|
130
185
|
.addOperation(Operation.setOptions({ signer: { ed25519PublicKey: approver.publicKey(), weight: 1 } }))
|
|
131
186
|
.addOperation(Operation.setOptions({ signer: { ed25519PublicKey: agent.publicKey(), weight: 1 } }))
|
|
132
|
-
.addOperation(Operation.setOptions({ masterWeight: 0, lowThreshold: 2, medThreshold: 2, highThreshold: 2 }))
|
|
133
|
-
|
|
187
|
+
.addOperation(Operation.setOptions({ masterWeight: 0, lowThreshold: 2, medThreshold: 2, highThreshold: 2 }));
|
|
188
|
+
if (token.getAssetType() !== "native") {
|
|
189
|
+
setup.addOperation(Operation.changeTrust({ asset: token, limit: "100000000" }));
|
|
190
|
+
}
|
|
191
|
+
await sub(h, pass, setup.setTimeout(300).build(), vault);
|
|
134
192
|
return new StellarVault(vault.publicKey(), token, {
|
|
135
193
|
principal: principal.publicKey(), approver: approver.publicKey(), agent: agent.publicKey(),
|
|
136
194
|
}, network);
|
|
@@ -151,7 +209,7 @@ export class StellarVault {
|
|
|
151
209
|
}
|
|
152
210
|
/** Point this vault at USDC instead of a custom token. */
|
|
153
211
|
useUsdc() { this.token = StellarVault.usdcToken(this.network); return this; }
|
|
154
|
-
/** Re-attach to an existing vault by account id. */
|
|
212
|
+
/** Re-attach to an existing vault by account id. `issuer` is omitted for XLM. */
|
|
155
213
|
static async load(address, tokenCode, issuer, signers, network) {
|
|
156
214
|
const h = new Horizon.Server(HORIZON_URL[network]);
|
|
157
215
|
const acc = await h.loadAccount(address);
|
|
@@ -159,11 +217,16 @@ export class StellarVault {
|
|
|
159
217
|
if (!found.some((s) => s.key === signers.principal)) {
|
|
160
218
|
throw new Error(`${address} is not a Paydlux vault (principal ${signers.principal} is not a signer)`);
|
|
161
219
|
}
|
|
162
|
-
return new StellarVault(address,
|
|
220
|
+
return new StellarVault(address, parseToken(tokenCode, issuer), signers, network);
|
|
163
221
|
}
|
|
164
222
|
// ------------------------------------------------------------------ money
|
|
165
223
|
/** The seller must opt in to holding our token before we can pay them. */
|
|
166
224
|
async trustToken(seller, limit = "100000000", sellerKeypair) {
|
|
225
|
+
// XLM needs no trustline — every account on the ledger can already hold it.
|
|
226
|
+
// Sending changeTrust for a native asset is an outright ledger error, so
|
|
227
|
+
// this is a no-op rather than a call that would abort the transaction.
|
|
228
|
+
if (this.isNative)
|
|
229
|
+
return;
|
|
167
230
|
await sub(this.horizon, PASSPHRASE[this.network], (await this.tb(seller))
|
|
168
231
|
.addOperation(Operation.changeTrust({ asset: this.token, limit }))
|
|
169
232
|
.setTimeout(300).build(), sellerKeypair);
|
|
@@ -184,10 +247,34 @@ export class StellarVault {
|
|
|
184
247
|
const r = await fetch(`${HORIZON_URL[this.network]}/transactions/${hash}`).catch(() => null);
|
|
185
248
|
return !!r && r.ok;
|
|
186
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Does a Horizon balance/payment record name *this* vault's token?
|
|
252
|
+
*
|
|
253
|
+
* The old code hard-coded "not native" in two places, which quietly made a
|
|
254
|
+
* native-XLM vault blind to its own money: balance() reported 0 and
|
|
255
|
+
* paidToday() reported 0 spent, so the daily budget never moved no matter how
|
|
256
|
+
* much XLM left the vault. Asset identity has to be one comparison that
|
|
257
|
+
* handles both cases, because a budget that reads as unspent is worse than no
|
|
258
|
+
* budget at all.
|
|
259
|
+
*/
|
|
260
|
+
isOurAsset(rec) {
|
|
261
|
+
if (this.isNative) {
|
|
262
|
+
// Native XLM records carry asset_type "native" and no code/issuer at all.
|
|
263
|
+
return rec.asset_type === "native";
|
|
264
|
+
}
|
|
265
|
+
return rec.asset_type !== "native"
|
|
266
|
+
&& rec.asset_type !== "liquidity_pool_shares"
|
|
267
|
+
&& rec.asset_code === this.token.getCode()
|
|
268
|
+
&& rec.asset_issuer === this.token.getIssuer();
|
|
269
|
+
}
|
|
270
|
+
/** True when this vault settles in XLM itself rather than a token. */
|
|
271
|
+
// stellar-base has no Native subclass: a native asset is an Asset whose
|
|
272
|
+
// getAssetType() is "native" and whose getIssuer() is undefined. Comparing
|
|
273
|
+
// constructors would silently treat XLM as a token with no issuer.
|
|
274
|
+
get isNative() { return this.token.getAssetType() === "native"; }
|
|
187
275
|
async balance(account = this.address) {
|
|
188
276
|
const acc = await this.horizon.loadAccount(account);
|
|
189
|
-
const b = acc.balances.find((x) =>
|
|
190
|
-
&& x.asset_code === this.token.getCode() && x.asset_issuer === this.token.getIssuer());
|
|
277
|
+
const b = acc.balances.find((x) => this.isOurAsset(x));
|
|
191
278
|
return b?.balance ?? "0";
|
|
192
279
|
}
|
|
193
280
|
// --------------------------------------------------------------- mandates
|
|
@@ -203,6 +290,16 @@ export class StellarVault {
|
|
|
203
290
|
* query away, and it survives restarts, crashes and redeploys.
|
|
204
291
|
*/
|
|
205
292
|
async paidToday(payee) {
|
|
293
|
+
// A payee that is not an address string compares unequal to every `to`
|
|
294
|
+
// field on the ledger, so the walk finds nothing and reports 0 spent — a
|
|
295
|
+
// budget that reads as unspent, which is the exact failure this query
|
|
296
|
+
// exists to prevent. Fail loudly instead.
|
|
297
|
+
if (typeof payee !== "string" || !/^G[A-Z2-7]{55}$/.test(payee)) {
|
|
298
|
+
throw new Error(`paidToday needs a payee account id (G…56 chars), got `
|
|
299
|
+
+ (payee && typeof payee === "object"
|
|
300
|
+
? `an object — pass payee.address, not the payee record`
|
|
301
|
+
: JSON.stringify(payee)));
|
|
302
|
+
}
|
|
206
303
|
const start = new Date();
|
|
207
304
|
start.setUTCHours(0, 0, 0, 0);
|
|
208
305
|
let total = 0;
|
|
@@ -226,9 +323,9 @@ export class StellarVault {
|
|
|
226
323
|
continue;
|
|
227
324
|
if (e.to !== payee)
|
|
228
325
|
continue;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
326
|
+
// Native XLM counts too. Skipping it here is how an XLM vault would pay
|
|
327
|
+
// its whole daily budget and still be told it had the full budget left.
|
|
328
|
+
if (!this.isOurAsset(e))
|
|
232
329
|
continue;
|
|
233
330
|
total += Number(e.amount);
|
|
234
331
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
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",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"test:dist": "npm run build && tsx stellar/dist-check.ts",
|
|
36
37
|
"demo": "node sdk/demo.ts",
|
|
37
38
|
"demo:stellar": "tsx stellar/demo.ts",
|
|
38
39
|
"setup:stellar": "tsx stellar/onboard.ts",
|
|
@@ -49,7 +50,21 @@
|
|
|
49
50
|
"test:seed": "DEVELOPER_DIR=/Library/Developer/CommandLineTools /usr/bin/swiftc -target arm64-apple-macosx14.0 ios/PaydluxApprover/Seed.swift ios/PaydluxApprover/Hex.swift ios/Checks/main.swift -o \"$TMPDIR/seedcheck\" && \"$TMPDIR/seedcheck\"",
|
|
50
51
|
"typecheck": "tsc --noEmit",
|
|
51
52
|
"app": "tsx stellar/cli.ts",
|
|
52
|
-
"app:mainnet": "tsx stellar/cli.ts setup --mainnet"
|
|
53
|
+
"app:mainnet": "tsx stellar/cli.ts setup --mainnet --file paydlux-mainnet.json --token XLM --max 2 --daily 20",
|
|
54
|
+
"fund:check": "tsx stellar/check-address.ts GARPB3CO3JMMED3ETFRSSZXCH2X7UTXW4FNUP3Z7HDMBKAC2H355W4SA",
|
|
55
|
+
"test:showcase": "tsx stellar/showcase-check.ts",
|
|
56
|
+
"test:native": "tsx stellar/native-check.ts",
|
|
57
|
+
"test:receive": "tsx stellar/receive-check.ts",
|
|
58
|
+
"test:xlm": "tsx stellar/xlm-check.ts",
|
|
59
|
+
"test:donate": "tsx stellar/donate-check.ts",
|
|
60
|
+
"donate:live": "tsx stellar/donate-live.ts",
|
|
61
|
+
"app:mainnet:sell": "tsx stellar/cli.ts sell --file paydlux-mainnet.json --split --tls --price 1 --route /market/data --max 2 --daily 20",
|
|
62
|
+
"app:testnet:sell": "tsx stellar/cli.ts sell --split --tls --showcase --price 1 --route /market/data --max 10 --daily 50",
|
|
63
|
+
"app:donate": "tsx stellar/cli.ts donate --file paydlux-mainnet.json --tls --price 1",
|
|
64
|
+
"site:qr": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg",
|
|
65
|
+
"address:check": "tsx stellar/check-address.ts",
|
|
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"
|
|
53
68
|
},
|
|
54
69
|
"keywords": [
|
|
55
70
|
"x402",
|
|
@@ -75,10 +90,12 @@
|
|
|
75
90
|
"access": "public"
|
|
76
91
|
},
|
|
77
92
|
"dependencies": {
|
|
78
|
-
"@stellar/stellar-sdk": "^17.4.0"
|
|
93
|
+
"@stellar/stellar-sdk": "^17.4.0",
|
|
94
|
+
"qrcode-generator": "^2.0.4"
|
|
79
95
|
},
|
|
80
96
|
"devDependencies": {
|
|
81
97
|
"@types/node": "^26.6.6",
|
|
98
|
+
"jsqr": "^1.4.0",
|
|
82
99
|
"tsx": "^4.23.15",
|
|
83
100
|
"typescript": "^7.0.2",
|
|
84
101
|
"viem": "^2.57.13",
|
package/phone-app/app.js
CHANGED
|
@@ -289,7 +289,7 @@ function row(rec) {
|
|
|
289
289
|
const btns = document.createElement("div");
|
|
290
290
|
btns.className = "btns";
|
|
291
291
|
const yes = document.createElement("button");
|
|
292
|
-
yes.textContent = "Review";
|
|
292
|
+
yes.textContent = "Review & approve";
|
|
293
293
|
yes.addEventListener("click", () => openReview(rec.id));
|
|
294
294
|
const no = document.createElement("button");
|
|
295
295
|
no.className = "danger";
|
|
@@ -297,6 +297,13 @@ function row(rec) {
|
|
|
297
297
|
no.addEventListener("click", () => reject(rec.id));
|
|
298
298
|
btns.append(yes, no);
|
|
299
299
|
foot.append(btns);
|
|
300
|
+
// Say what the next screen asks for. Approving is a deliberate hold, not a
|
|
301
|
+
// tap, and a person who expects a button on this row concludes the page is
|
|
302
|
+
// broken and gives up before ever seeing the hold control.
|
|
303
|
+
const how = document.createElement("div");
|
|
304
|
+
how.className = "howto";
|
|
305
|
+
how.textContent = `Approving takes a ${holdSeconds()}-second hold on the next screen. Reject is one tap.`;
|
|
306
|
+
wrap.append(how);
|
|
300
307
|
}
|
|
301
308
|
|
|
302
309
|
wrap.append(top, uri, wants, foot);
|
|
@@ -446,6 +453,10 @@ function backToQueue() {
|
|
|
446
453
|
// is one tap and Approve needs a deliberate hold. This is what stops a pocket
|
|
447
454
|
// tap, a mis-tap, and the person who taps Approve and only then starts reading.
|
|
448
455
|
const HOLD_MS = 1200;
|
|
456
|
+
// The queue row quotes this number, so it is stated once and read from the
|
|
457
|
+
// constant. Copy that says "one second" over a 1.2s gesture teaches the wrong
|
|
458
|
+
// gesture and reads as broken the first time it does not fire.
|
|
459
|
+
const holdSeconds = () => (HOLD_MS / 1000).toFixed(1).replace(/\.0$/, "");
|
|
449
460
|
let holdStart = 0, holdTimer = null;
|
|
450
461
|
|
|
451
462
|
function resetHold() {
|
package/phone-app/index.html
CHANGED
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
.item .foot { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-top:12px }
|
|
130
130
|
.item .ago { font-size:11.5px; color:var(--dim) }
|
|
131
131
|
.item .btns { display:flex; gap:8px }
|
|
132
|
+
.item .howto { flex-basis:100%; font-size:12.5px; color:var(--dim); margin-top:2px }
|
|
132
133
|
.item .btns button { width:auto; margin:0; min-height:44px; padding:11px 18px; font-size:15px }
|
|
133
134
|
.empty { color:var(--dim); font-size:15px; padding:18px 2px; text-align:center }
|
|
134
135
|
.hist { opacity:.6 }
|