@paydlux/agent 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,296 @@
1
+ // Paydlux on Stellar — the vault.
2
+ //
3
+ // No smart contract. The whole product is three native Stellar primitives:
4
+ //
5
+ // 1. an issued asset -> the payment token
6
+ // 2. a multisig account -> the vault (release needs 2 of the signers)
7
+ // 3. a pre-authenticated tx -> the spending mandate, as a hash-signer
8
+ //
9
+ // The mandate is the interesting one. We pre-build a transaction whose
10
+ // operations are EXACTLY "pay N tokens to this payee, inside this time window,
11
+ // once", take its hash, and register that hash as a weight-1 signer on the
12
+ // vault. The agent is also weight-1, threshold is 2. So the agent can reach
13
+ // threshold only by presenting that exact transaction. It cannot change the
14
+ // amount, the payee, the window, or run it twice — the ledger says no.
15
+ //
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";
18
+ /**
19
+ * Circle's own USDC issuer on Stellar. Not a wrapped token, not a bridge:
20
+ * native, and Circle is the issuer. Anyone can put "USDC" on a token name —
21
+ * only these two accounts are real money. Never accept any other issuer.
22
+ */
23
+ export const USDC_ISSUER = {
24
+ public: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
25
+ testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
26
+ };
27
+ /** A rejected Stellar transaction, with the ledger's own reason code. */
28
+ export class StellarTxError extends Error {
29
+ code;
30
+ constructor(code, cause) {
31
+ super(`stellar tx rejected: ${code}`);
32
+ this.code = code;
33
+ this.name = "StellarTxError";
34
+ this.cause = cause;
35
+ }
36
+ }
37
+ const HORIZON_URL = {
38
+ testnet: "https://horizon-testnet.stellar.org",
39
+ public: "https://horizon.stellar.org",
40
+ };
41
+ const PASSPHRASE = {
42
+ testnet: Networks.TESTNET,
43
+ public: Networks.PUBLIC,
44
+ };
45
+ const FRIENDBOT = "https://friendbot.stellar.org";
46
+ /** The network passphrase for a network name (needed to hash transactions). */
47
+ export function passphrase(network) { return PASSPHRASE[network]; }
48
+ export class StellarVault {
49
+ horizon;
50
+ network;
51
+ address;
52
+ token;
53
+ signers;
54
+ constructor(address, token, signers, network) {
55
+ this.address = address;
56
+ this.token = token;
57
+ this.signers = signers;
58
+ this.network = network;
59
+ this.horizon = new Horizon.Server(HORIZON_URL[network]);
60
+ }
61
+ // ---------------------------------------------------------------- lifecycle
62
+ /** Fund a key on testnet. No captcha, no phone, no faucet hunt. */
63
+ static async fundTestnet(kp) {
64
+ const r = await fetch(`${FRIENDBOT}?addr=${encodeURIComponent(kp.publicKey())}`);
65
+ if (!r.ok)
66
+ throw new Error(`friendbot failed: ${r.status} ${await r.text()}`);
67
+ return kp.publicKey();
68
+ }
69
+ /**
70
+ * Create a vault. `issuer` signs the token; the vault account itself is a
71
+ * multisig whose release threshold needs principal + approver, or agent +
72
+ * a pre-authorised mandate hash. Nothing else reaches threshold.
73
+ */
74
+ static async create(opts) {
75
+ const { network, issuer, principal, approver, agent } = opts;
76
+ const vault = opts.vaultSeed ? Keypair.fromSecret(opts.vaultSeed) : Keypair.random();
77
+ const h = new Horizon.Server(HORIZON_URL[network]);
78
+ const pass = PASSPHRASE[network];
79
+ // 1. the vault account itself
80
+ await sub(h, pass, (await tb(h, issuer.publicKey(), undefined, pass))
81
+ .addOperation(Operation.createAccount({
82
+ destination: vault.publicKey(),
83
+ startingBalance: opts.startingBalance ?? "10",
84
+ })).setTimeout(300).build(), issuer);
85
+ // 2. signers + thresholds, in one atomic tx.
86
+ // master weight 0: the vault's own seed can never act alone again.
87
+ // The trustline has to ride in this same transaction: once masterWeight is
88
+ // 0 the vault seed cannot sign anything on its own, so a later changeTrust
89
+ // would need two human signatures before the vault can even hold tokens.
90
+ const token = new Asset(opts.tokenCode, issuer.publicKey());
91
+ await sub(h, pass, (await tb(h, vault.publicKey(), undefined, pass))
92
+ .addOperation(Operation.changeTrust({ asset: token, limit: "100000000" }))
93
+ .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: principal.publicKey(), weight: 1 } }))
94
+ .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: approver.publicKey(), weight: 1 } }))
95
+ .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: agent.publicKey(), weight: 1 } }))
96
+ .addOperation(Operation.setOptions({ masterWeight: 0, lowThreshold: 2, medThreshold: 2, highThreshold: 2 }))
97
+ .setTimeout(300).build(), vault);
98
+ return new StellarVault(vault.publicKey(), token, {
99
+ principal: principal.publicKey(), approver: approver.publicKey(), agent: agent.publicKey(),
100
+ }, network);
101
+ }
102
+ /**
103
+ * A vault that settles in real Circle USDC instead of a test token.
104
+ * Circle mints USDC, so there is no issuer key for us to hold: the vault
105
+ * just needs a trustline, and money arrives from an exchange or Circle's
106
+ * faucet on testnet. Everything else (mandates, thresholds, replay) is the
107
+ * same code.
108
+ */
109
+ static usdcToken(network) { return new Asset("USDC", USDC_ISSUER[network]); }
110
+ /** Add a USDC trustline to an existing vault (needs both human keys). */
111
+ async trustUsdc(principal, approver, limit = "1000000") {
112
+ return sub(this.horizon, PASSPHRASE[this.network], (await this.tb(this.address))
113
+ .addOperation(Operation.changeTrust({ asset: StellarVault.usdcToken(this.network), limit }))
114
+ .setTimeout(300).build(), principal, approver);
115
+ }
116
+ /** Point this vault at USDC instead of a custom token. */
117
+ useUsdc() { this.token = StellarVault.usdcToken(this.network); return this; }
118
+ /** Re-attach to an existing vault by account id. */
119
+ static async load(address, tokenCode, issuer, signers, network) {
120
+ const h = new Horizon.Server(HORIZON_URL[network]);
121
+ const acc = await h.loadAccount(address);
122
+ const found = acc.signers ?? [];
123
+ if (!found.some((s) => s.key === signers.principal)) {
124
+ throw new Error(`${address} is not a Paydlux vault (principal ${signers.principal} is not a signer)`);
125
+ }
126
+ return new StellarVault(address, new Asset(tokenCode, issuer), signers, network);
127
+ }
128
+ // ------------------------------------------------------------------ money
129
+ /** The seller must opt in to holding our token before we can pay them. */
130
+ async trustToken(seller, limit = "100000000", sellerKeypair) {
131
+ await sub(this.horizon, PASSPHRASE[this.network], (await this.tb(seller))
132
+ .addOperation(Operation.changeTrust({ asset: this.token, limit }))
133
+ .setTimeout(300).build(), sellerKeypair);
134
+ }
135
+ /** Move tokens into the vault. Only the vault's signers can move them out. */
136
+ async deposit(amount, from) {
137
+ await sub(this.horizon, PASSPHRASE[this.network], (await this.tb(from.publicKey()))
138
+ .addOperation(Operation.payment({ destination: this.address, asset: this.token, amount }))
139
+ .setTimeout(300).build(), from);
140
+ return this.balance();
141
+ }
142
+ /**
143
+ * Has this transaction already been applied? Horizon de-duplicates an
144
+ * identical resubmission and returns the original result, so "submit
145
+ * succeeded" does NOT mean "money moved just now". Sellers must check.
146
+ */
147
+ async txExists(hash) {
148
+ const r = await fetch(`${HORIZON_URL[this.network]}/transactions/${hash}`).catch(() => null);
149
+ return !!r && r.ok;
150
+ }
151
+ async balance(account = this.address) {
152
+ const acc = await this.horizon.loadAccount(account);
153
+ const b = acc.balances.find((x) => x.asset_type !== "native" && x.asset_type !== "liquidity_pool_shares"
154
+ && x.asset_code === this.token.getCode() && x.asset_issuer === this.token.getIssuer());
155
+ return b?.balance ?? "0";
156
+ }
157
+ // --------------------------------------------------------------- mandates
158
+ /**
159
+ * Build a mandate and register it on the vault.
160
+ *
161
+ * Returns the envelope (base64 XDR) to hand to the agent plus its hash. The
162
+ * agent submits it with its own signature; the pre-auth signer supplies the
163
+ * second. The agent cannot alter it: any edit changes the hash, and the hash
164
+ * is the signer.
165
+ */
166
+ async createMandate(spec, principal, approver) {
167
+ if (spec.payee === this.address)
168
+ throw new Error("payee cannot be the vault");
169
+ if (!(Number(spec.amount) > 0))
170
+ throw new Error("amount must be positive");
171
+ const acc = await this.horizon.loadAccount(this.address);
172
+ const now = Math.floor(Date.now() / 1000);
173
+ const notBefore = now + (spec.notBefore ?? -60);
174
+ const expiresAt = now + (spec.ttl ?? 3600);
175
+ // The setOptions below takes sequence S+1, so the envelope must claim S+2.
176
+ const eb = (await this.tbAt(this.address, BigInt(acc.sequence) + 1n))
177
+ .addOperation(Operation.payment({
178
+ destination: spec.payee, asset: this.token, amount: spec.amount,
179
+ }))
180
+ .setTimebounds(notBefore, expiresAt);
181
+ if (spec.memo)
182
+ eb.addMemo(Memo.text(spec.memo));
183
+ const envelope = eb.build();
184
+ const hash = envelope.hash().toString("hex");
185
+ // register the hash as a weight-1 signer. Two signatures now exist that
186
+ // reach threshold 2: (agent + this hash) and (principal + approver).
187
+ // setOptions needs med threshold 2, so registering a mandate always takes
188
+ // both human keys. The agent can never write its own spending rules.
189
+ //
190
+ // A pre-auth transaction is bound to a sequence number, so exactly ONE
191
+ // mandate can be live: the newest. Any older one is already in the past and
192
+ // can never run again. We revoke the old ones in the same transaction so
193
+ // the state is never ambiguous — one open order at a time, on purpose.
194
+ const b = await this.tb(this.address);
195
+ for (const s of (acc.signers ?? []).filter((s) => s.key.startsWith("T") && s.weight > 0)) {
196
+ b.addOperation(Operation.setOptions({ signer: { preAuthTx: StrKey.decodePreAuthTx(s.key), weight: 0 } }));
197
+ }
198
+ b.addOperation(Operation.setOptions({ signer: { preAuthTx: hash, weight: 1 } }));
199
+ await sub(this.horizon, PASSPHRASE[this.network], b.setTimeout(expiresAt - now + 60).build(), principal, approver);
200
+ return { hash, envelope: envelope.toXDR(), spec, sequence: (BigInt(acc.sequence) + 2n).toString(), expiresAt };
201
+ }
202
+ /** The agent's side: sign the envelope and submit. That is all it can do. */
203
+ async executeMandate(mandate, agentKeypair) {
204
+ const tx = new Transaction(mandate.envelope, PASSPHRASE[this.network]);
205
+ tx.sign(agentKeypair);
206
+ return sub(this.horizon, PASSPHRASE[this.network], tx);
207
+ }
208
+ /**
209
+ * The human path: approve a payment directly. Needs principal + approver,
210
+ * so a stolen agent key cannot use it and a stolen phone cannot use it alone.
211
+ */
212
+ async release(payee, amount, principal, approver, memo) {
213
+ const b = (await this.tb(this.address))
214
+ .addOperation(Operation.payment({ destination: payee, asset: this.token, amount }))
215
+ .setTimeout(300);
216
+ if (memo)
217
+ b.addMemo(Memo.text(memo));
218
+ return sub(this.horizon, PASSPHRASE[this.network], b.build(), principal, approver);
219
+ }
220
+ /**
221
+ * Kill every outstanding mandate at once. Any accepted transaction bumps the
222
+ * vault's sequence, which invalidates all pre-authorized hashes pointing at a
223
+ * later sequence. This is the panic button: it needs principal + approver,
224
+ * so a stolen agent key cannot pull it and a stolen phone cannot pull it alone.
225
+ */
226
+ async revokeAll(principal, approver) {
227
+ const acc = await this.horizon.loadAccount(this.address);
228
+ const outstanding = (acc.signers ?? []).filter((s) => s.key.startsWith("T") && s.weight > 0);
229
+ if (!outstanding.length)
230
+ return null;
231
+ const b = await this.tb(this.address);
232
+ for (const s of outstanding) {
233
+ // Horizon returns the preauth signer as a strkey "T…" — decode it back to
234
+ // the raw 32-byte hash the SDK expects.
235
+ b.addOperation(Operation.setOptions({ signer: { preAuthTx: StrKey.decodePreAuthTx(s.key), weight: 0 } }));
236
+ }
237
+ const res = await sub(this.horizon, PASSPHRASE[this.network], b.setTimeout(300).build(), principal, approver);
238
+ return { revoked: outstanding.length, tx: res?.hash };
239
+ }
240
+ /** Rotate the agent key: a compromised agent is replaced, mandates die with it. */
241
+ async rotateAgent(oldAgent, newAgentPub, human) {
242
+ return sub(this.horizon, PASSPHRASE[this.network], (await this.tb(this.address))
243
+ .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: oldAgent.publicKey(), weight: 0 } }))
244
+ .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: newAgentPub, weight: 1 } }))
245
+ .setTimeout(300).build(), human, oldAgent);
246
+ }
247
+ /** Who can sign for this vault right now. */
248
+ async signerSummary() {
249
+ const acc = await this.horizon.loadAccount(this.address);
250
+ const t = acc.thresholds ?? {};
251
+ return {
252
+ thresholds: {
253
+ low: t.low_threshold ?? t.lowThreshold,
254
+ med: t.med_threshold ?? t.medThreshold,
255
+ high: t.high_threshold ?? t.highThreshold,
256
+ },
257
+ signers: (acc.signers ?? []).map((s) => ({
258
+ key: s.key, weight: s.weight,
259
+ type: s.key.startsWith("T") ? "preauth-mandate" : s.weight === 0 ? "disabled-master" : "key",
260
+ })),
261
+ };
262
+ }
263
+ // ---------------------------------------------------------------- plumbing
264
+ async tb(pub) {
265
+ return tb(this.horizon, pub, undefined, PASSPHRASE[this.network]);
266
+ }
267
+ async tbAt(pub, at) {
268
+ return tb(this.horizon, pub, at, PASSPHRASE[this.network]);
269
+ }
270
+ }
271
+ async function tb(h, pub, afterSeq, pass) {
272
+ const acc = afterSeq === undefined
273
+ ? await h.loadAccount(pub)
274
+ : new Account(pub, afterSeq.toString());
275
+ return new TransactionBuilder(acc, { fee: (await h.fetchBaseFee()).toString(), networkPassphrase: pass });
276
+ }
277
+ async function sub(h, _pass, tx, ...signers) {
278
+ if (signers.length)
279
+ tx.sign(...signers);
280
+ try {
281
+ return await h.submitTransaction(tx);
282
+ }
283
+ catch (e) {
284
+ // stellar-sdk throws BadResponse with the result codes attached; older
285
+ // shapes put them under response.data.extras. Try both, then the xdr.
286
+ let codes;
287
+ try {
288
+ codes = e?.resultResultCodes?.()?.results;
289
+ }
290
+ catch { /* shape differs */ }
291
+ codes ??= e?.response?.data?.extras?.result_codes?.results;
292
+ const detail = codes?.join(",") || e?.response?.data?.extras?.result_xdr || e?.message || String(e);
293
+ throw new StellarTxError(detail, e);
294
+ }
295
+ }
296
+ export { Keypair, Asset, Networks };
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "sideEffects": false,
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "demo": "node sdk/demo.ts",
26
+ "demo:stellar": "tsx stellar/demo.ts",
27
+ "setup:stellar": "tsx stellar/onboard.ts",
28
+ "story:stellar": "tsx stellar/story.ts",
29
+ "test": "cd contracts && forge test",
30
+ "test:stellar": "tsx stellar/demo.ts",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "keywords": [
34
+ "x402",
35
+ "http-402",
36
+ "stellar",
37
+ "usdc",
38
+ "ai-agents",
39
+ "agent-payments",
40
+ "escrow",
41
+ "payments"
42
+ ],
43
+ "author": "Paydlux",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Mesher589/paydlux.git"
48
+ },
49
+ "homepage": "https://paydlux.com",
50
+ "bugs": {
51
+ "url": "https://github.com/Mesher589/paydlux/issues"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "dependencies": {
57
+ "stellar-sdk": "^13.3.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^26.6.6",
61
+ "tsx": "^4.23.15",
62
+ "typescript": "^7.0.2",
63
+ "viem": "^2.57.13",
64
+ "xrpl": "^5.3.0"
65
+ }
66
+ }