@paydlux/agent 0.1.18 → 0.1.19

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/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
- const token = new Asset(opts.tokenCode, opts.tokenIssuer ?? issuer.publicKey());
127
- await sub(h, pass, (await tb(h, vault.publicKey(), undefined, pass))
128
- .addOperation(Operation.changeTrust({ asset: token, limit: "100000000" }))
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
- .setTimeout(300).build(), vault);
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, new Asset(tokenCode, issuer), signers, network);
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) => x.asset_type !== "native" && x.asset_type !== "liquidity_pool_shares"
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
- if (e.asset_type === "native")
230
- continue;
231
- if (e.asset_code !== this.token.getCode() || e.asset_issuer !== this.token.getIssuer())
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.18",
3
+ "version": "0.1.19",
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",
@@ -49,7 +49,21 @@
49
49
  "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
50
  "typecheck": "tsc --noEmit",
51
51
  "app": "tsx stellar/cli.ts",
52
- "app:mainnet": "tsx stellar/cli.ts setup --mainnet"
52
+ "app:mainnet": "tsx stellar/cli.ts setup --mainnet --file paydlux-mainnet.json --token XLM --max 2 --daily 20",
53
+ "fund:check": "tsx stellar/check-address.ts GARPB3CO3JMMED3ETFRSSZXCH2X7UTXW4FNUP3Z7HDMBKAC2H355W4SA",
54
+ "test:showcase": "tsx stellar/showcase-check.ts",
55
+ "test:native": "tsx stellar/native-check.ts",
56
+ "test:receive": "tsx stellar/receive-check.ts",
57
+ "test:xlm": "tsx stellar/xlm-check.ts",
58
+ "test:donate": "tsx stellar/donate-check.ts",
59
+ "donate:live": "tsx stellar/donate-live.ts",
60
+ "app:mainnet:sell": "tsx stellar/cli.ts sell --file paydlux-mainnet.json --split --tls --price 1 --route /market/data --max 2 --daily 20",
61
+ "app:testnet:sell": "tsx stellar/cli.ts sell --split --tls --showcase --price 1 --route /market/data --max 10 --daily 50",
62
+ "app:donate": "tsx stellar/cli.ts donate --file paydlux-mainnet.json --tls --price 1",
63
+ "site:qr": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg",
64
+ "address:check": "tsx stellar/check-address.ts",
65
+ "test:address": "tsx stellar/address-check.ts",
66
+ "site:deploy": "tsx stellar/make-site-qr.ts paydlux-mainnet.json site/qr.svg && wrangler deploy"
53
67
  },
54
68
  "keywords": [
55
69
  "x402",
@@ -75,10 +89,12 @@
75
89
  "access": "public"
76
90
  },
77
91
  "dependencies": {
78
- "@stellar/stellar-sdk": "^17.4.0"
92
+ "@stellar/stellar-sdk": "^17.4.0",
93
+ "qrcode-generator": "^2.0.4"
79
94
  },
80
95
  "devDependencies": {
81
96
  "@types/node": "^26.6.6",
97
+ "jsqr": "^1.4.0",
82
98
  "tsx": "^4.23.15",
83
99
  "typescript": "^7.0.2",
84
100
  "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() {
@@ -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 }