@agentpayments/edge 0.1.0 → 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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/index.js +101 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,6 +82,12 @@ export default { fetch: (req, env, ctx) => gate(req, env, ctx) };
82
82
  | `SOLANA_RPC_URL` | No | Custom Solana RPC endpoint. Defaults by debug flag. |
83
83
  | `USDC_MINT` | No | Custom USDC mint address. Defaults by debug flag. |
84
84
  | `DEBUG` | No | `"true"` = devnet. `"false"` = mainnet (default varies by adapter). |
85
+ | `AGENTPAYMENTS_API_KEY` | No | AgentPayments hosted-platform API key (`ap_live_...`). When set, agent keys are issued and metered via the platform instead of self-signed locally. See **Hosted Platform Mode** below. |
86
+ | `AGENTPAYMENTS_PLATFORM_URL` | No | Override for a self-hosted platform API. |
87
+
88
+ ## Hosted Platform Mode
89
+
90
+ Setting `AGENTPAYMENTS_API_KEY` switches agent-key issuance from local (`ag_...`) to platform-issued (`agp_...`), and — when the platform account has an on-chain fee configured — every 402 response's custom `payment` object gains a `platform_fee` field describing a second required USDC transfer, to be sent in the **same Solana transaction** as the vendor payment. Missing that second transfer is treated as an unpaid request, same as any other invalid payment. This is opt-in per vendor account and has no effect on self-hosted deployments (no `AGENTPAYMENTS_API_KEY`). The standards-compliant `accepts[]`/`X-PAYMENT-REQUIRED` x402 fields are untouched — they still describe only the vendor leg.
85
91
 
86
92
  ## Security Features
87
93
 
package/index.js CHANGED
@@ -27,7 +27,7 @@ const USDC_DECIMALS = 6;
27
27
  const X402_VERSION = 1;
28
28
  const SOLANA_CHAIN_ID_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
29
29
  const SOLANA_CHAIN_ID_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1';
30
- const PLATFORM_API_URL = 'https://api.agentpayments.dev';
30
+ const PLATFORM_API_URL = 'https://api.agentpayments.cloud';
31
31
  const HOSTED_KEY_PREFIX = 'agp_';
32
32
 
33
33
  // ---------------------------------------------------------------------------
@@ -251,30 +251,50 @@ class EdgePlatformClient {
251
251
  this.apiKey = apiKey;
252
252
  this.platformUrl = platformUrl.replace(/\/$/, '');
253
253
  this._verificationSecret = null;
254
- this._secretFetch = null;
254
+ this._platformFeeInfo = undefined; // undefined = not fetched yet; null = fetched, no fee configured
255
+ this._accountFetch = null;
255
256
  }
256
257
 
257
258
  _authHeaders() {
258
259
  return { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
259
260
  }
260
261
 
261
- async getVerificationSecret() {
262
- if (this._verificationSecret) return this._verificationSecret;
263
- if (this._secretFetch) return this._secretFetch;
264
- this._secretFetch = fetch(`${this.platformUrl}/v1/account`, { headers: this._authHeaders() })
262
+ _fetchAccount() {
263
+ if (this._accountFetch) return this._accountFetch;
264
+ this._accountFetch = fetch(`${this.platformUrl}/v1/account`, { headers: this._authHeaders() })
265
265
  .then((r) => {
266
266
  if (!r.ok) throw new Error(`Platform /v1/account returned ${r.status}`);
267
267
  return r.json();
268
268
  })
269
269
  .then((data) => {
270
270
  this._verificationSecret = data.verificationSecret;
271
- return data.verificationSecret;
271
+ this._platformFeeInfo = data.platformFeeWallet
272
+ ? { wallet: data.platformFeeWallet, ratePct: data.platformFeeRatePct }
273
+ : null;
274
+ return data;
272
275
  })
273
276
  .catch((err) => {
274
- this._secretFetch = null;
277
+ this._accountFetch = null;
275
278
  throw err;
276
279
  });
277
- return this._secretFetch;
280
+ return this._accountFetch;
281
+ }
282
+
283
+ async getVerificationSecret() {
284
+ if (this._verificationSecret) return this._verificationSecret;
285
+ await this._fetchAccount();
286
+ return this._verificationSecret;
287
+ }
288
+
289
+ /**
290
+ * Lazily fetch + cache the on-chain platform fee config (same request as
291
+ * getVerificationSecret — no extra round trip if already fetched).
292
+ * Returns { wallet, ratePct } or null if no fee is configured.
293
+ */
294
+ async getPlatformFeeInfo() {
295
+ if (this._platformFeeInfo !== undefined) return this._platformFeeInfo;
296
+ await this._fetchAccount();
297
+ return this._platformFeeInfo;
278
298
  }
279
299
 
280
300
  async issueKey() {
@@ -332,7 +352,15 @@ async function rpcCallWithFallback(rpcUrls, method, params, opts) {
332
352
  throw lastError;
333
353
  }
334
354
 
335
- async function verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint) {
355
+ /**
356
+ * Verify payment on-chain.
357
+ *
358
+ * feeInfo, when set (hosted-platform mode with an on-chain fee configured), is
359
+ * { wallet, ratePct }. When set, the SAME transaction that carries the vendor
360
+ * payment must also carry a USDC transfer to feeInfo.wallet of at least
361
+ * minPayment * ratePct / 100, or the payment is treated as unverified.
362
+ */
363
+ export async function verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint, minPayment = MIN_PAYMENT, feeInfo = null) {
336
364
  try {
337
365
  // commitment: 'finalized' — confirmed blocks can be rolled back (rare but possible).
338
366
  const ataData = await rpcCallWithFallback(rpcUrls, 'getTokenAccountsByOwner', [walletAddress, { mint: usdcMint }, { encoding: 'jsonParsed', commitment: 'finalized' }]);
@@ -343,6 +371,15 @@ async function verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint)
343
371
  const vendorUsdcAccounts = new Set(tokenAccounts);
344
372
  if (vendorUsdcAccounts.size === 0) return false; // vendor has no USDC account yet — no payment possible
345
373
 
374
+ let feeUsdcAccounts = null;
375
+ let feeAmountMicro = 0;
376
+ if (feeInfo) {
377
+ const feeAtaData = await rpcCallWithFallback(rpcUrls, 'getTokenAccountsByOwner', [feeInfo.wallet, { mint: usdcMint }, { encoding: 'jsonParsed', commitment: 'finalized' }]);
378
+ feeUsdcAccounts = new Set((feeAtaData.result?.value || []).map((entry) => entry.pubkey));
379
+ feeAmountMicro = Math.round(Math.round(minPayment * 1e6) * feeInfo.ratePct / 100);
380
+ if (feeUsdcAccounts.size === 0) return false; // fee wallet has no USDC account — fee can never be satisfied
381
+ }
382
+
346
383
  const addressesToScan = [walletAddress, ...tokenAccounts];
347
384
  const seen = new Set();
348
385
  const allSignatures = [];
@@ -376,6 +413,7 @@ async function verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint)
376
413
 
377
414
  let hasMemo = false;
378
415
  let hasPayment = false;
416
+ let hasFeePayment = !feeInfo; // vacuously satisfied when no fee is required
379
417
 
380
418
  for (const ix of allInstructions) {
381
419
  if (ix.program === 'spl-memo' || ix.programId === MEMO_PROGRAM) {
@@ -387,19 +425,24 @@ async function verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint)
387
425
  const parsed = ix.parsed || {};
388
426
  if (parsed.type === 'transfer' || parsed.type === 'transferChecked') {
389
427
  const info = parsed.info || {};
390
- // Payment must be delivered to one of the vendor's USDC token accounts.
391
- if (!vendorUsdcAccounts.has(info.destination)) continue;
428
+ // Payment must be delivered to one of the vendor's or fee wallet's USDC
429
+ // token accounts — anything else is irrelevant.
430
+ const isVendorDest = vendorUsdcAccounts.has(info.destination);
431
+ const isFeeDest = feeUsdcAccounts !== null && feeUsdcAccounts.has(info.destination);
432
+ if (!isVendorDest && !isFeeDest) continue;
392
433
  if (parsed.type === 'transferChecked' && info.mint !== usdcMint) continue;
393
434
  // Integer base-unit comparison — avoids float precision issues at threshold.
394
435
  const amountStr = info.tokenAmount?.amount ?? info.amount ?? '0';
395
436
  const amountMicro = parseInt(amountStr, 10);
396
- const minPaymentMicro = Math.round(MIN_PAYMENT * 1e6);
397
- if (!Number.isNaN(amountMicro) && amountMicro >= minPaymentMicro) hasPayment = true;
437
+ if (Number.isNaN(amountMicro)) continue;
438
+ const minPaymentMicro = Math.round(minPayment * 1e6);
439
+ if (isVendorDest && amountMicro >= minPaymentMicro) hasPayment = true;
440
+ else if (isFeeDest && amountMicro >= feeAmountMicro) hasFeePayment = true;
398
441
  }
399
442
  }
400
443
  }
401
444
 
402
- if (hasMemo && hasPayment) return true;
445
+ if (hasMemo && hasPayment && hasFeePayment) return true;
403
446
  }
404
447
  } catch (error) {
405
448
  gateLog('error', 'Solana RPC error', { error: error.message });
@@ -473,6 +516,30 @@ function buildX402PaymentRequirements({ walletAddress, mint, minPayment, debug,
473
516
  return req;
474
517
  }
475
518
 
519
+ /**
520
+ * Builds the custom `payment` object (NOT part of the x402 spec — that's
521
+ * buildX402PaymentRequirements above, which stays vendor-leg-only). When feeInfo
522
+ * is set (hosted-platform mode with an on-chain fee configured), adds a
523
+ * platform_fee field describing the second required transfer. Deliberately not
524
+ * added as a second x402 accepts[] entry — that would read to a spec-compliant
525
+ * client as an alternative payment method, not an additional requirement.
526
+ */
527
+ function buildPaymentField({ network, minPayment, walletAddress, memo, feeInfo, instructions }) {
528
+ const payment = { chain: 'solana', network, token: 'USDC', amount: String(minPayment), wallet_address: walletAddress, memo };
529
+ if (feeInfo) {
530
+ const feeAmountMicro = Math.round(Math.round(minPayment * 1e6) * feeInfo.ratePct / 100);
531
+ payment.platform_fee = {
532
+ wallet_address: feeInfo.wallet,
533
+ amount: String(feeAmountMicro / 1e6),
534
+ token: 'USDC',
535
+ rate_pct: feeInfo.ratePct,
536
+ note: 'Must be a second USDC transfer inside the SAME Solana transaction as the payment above, or access will be denied.',
537
+ };
538
+ }
539
+ if (instructions) payment.instructions = instructions;
540
+ return payment;
541
+ }
542
+
476
543
  /**
477
544
  * Like jsonResponse(body, 402) but adds x402Version, accepts[], and
478
545
  * the X-PAYMENT-REQUIRED header (base64-encoded PaymentRequirements).
@@ -633,6 +700,18 @@ export function createEdgeGate(options = {}) {
633
700
 
634
701
  if (!isBrowser(request)) {
635
702
  const agentKey = request.headers.get('X-Agent-Key');
703
+ const network = debug ? 'devnet' : 'mainnet-beta';
704
+
705
+ // Resolve the on-chain platform fee requirement once (hosted-platform mode
706
+ // only — always null for self-hosted vendors with no platformClient).
707
+ let feeInfo = null;
708
+ if (platformClient) {
709
+ try {
710
+ feeInfo = await platformClient.getPlatformFeeInfo();
711
+ } catch (err) {
712
+ gateLog('warn', 'Failed to fetch platform fee info, proceeding without fee enforcement', { error: err.message });
713
+ }
714
+ }
636
715
 
637
716
  if (!agentKey) {
638
717
  // Hosted mode: issue a metered platform key (agp_...).
@@ -649,19 +728,14 @@ export function createEdgeGate(options = {}) {
649
728
  } else {
650
729
  newKey = await generateAgentKey(secret);
651
730
  }
731
+ const noKeyInstructions = feeInfo
732
+ ? `Send ${minPayment} USDC on Solana ${debug ? 'devnet' : 'mainnet'} to ${walletAddress} with memo "${newKey}", AND in the SAME transaction send the platform fee (see platform_fee below) to ${feeInfo.wallet}. Then include the header X-Agent-Key: ${newKey} on all subsequent requests.`
733
+ : `Send ${minPayment} USDC on Solana ${debug ? 'devnet' : 'mainnet'} to ${walletAddress} with memo "${newKey}". Then include the header X-Agent-Key: ${newKey} on all subsequent requests.`;
652
734
  return paymentRequiredResponse({
653
735
  error: 'payment_required',
654
736
  message: 'Access requires a paid API key. A key has been generated for you below. Send a USDC payment on Solana with this key as the memo to activate it, then retry your request with the X-Agent-Key header.',
655
737
  your_key: newKey,
656
- payment: {
657
- chain: 'solana',
658
- network: debug ? 'devnet' : 'mainnet-beta',
659
- token: 'USDC',
660
- amount: String(minPayment),
661
- wallet_address: walletAddress,
662
- memo: newKey,
663
- instructions: `Send ${minPayment} USDC on Solana ${debug ? 'devnet' : 'mainnet'} to ${walletAddress} with memo "${newKey}". Then include the header X-Agent-Key: ${newKey} on all subsequent requests.`,
664
- },
738
+ payment: buildPaymentField({ network, minPayment, walletAddress, memo: newKey, feeInfo, instructions: noKeyInstructions }),
665
739
  }, { walletAddress, mint: usdcMint, minPayment, debug, agentKey: newKey, resource: url.pathname });
666
740
  }
667
741
 
@@ -708,24 +782,17 @@ export function createEdgeGate(options = {}) {
708
782
  error: 'payment_required',
709
783
  message: 'Key is valid but payment has not been verified on-chain yet. Please send the USDC payment and allow a few moments for confirmation.',
710
784
  your_key: agentKey,
711
- payment: { chain: 'solana', network: debug ? 'devnet' : 'mainnet-beta', token: 'USDC', amount: String(minPayment), wallet_address: walletAddress, memo: agentKey },
785
+ payment: buildPaymentField({ network, minPayment, walletAddress, memo: agentKey, feeInfo }),
712
786
  }, { walletAddress, mint: usdcMint, minPayment, debug, agentKey, resource: url.pathname });
713
787
  }
714
- const paid = await verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint);
788
+ const paid = await verifyPaymentOnChain(agentKey, walletAddress, rpcUrls, usdcMint, minPayment, feeInfo);
715
789
  await store.setCachedPayment(agentKey, paid, paid ? PAYMENT_CACHE_TTL : NEGATIVE_CACHE_TTL_MS);
716
790
  if (!paid) {
717
791
  return paymentRequiredResponse({
718
792
  error: 'payment_required',
719
793
  message: 'Key is valid but payment has not been verified on-chain yet. Please send the USDC payment and allow a few moments for confirmation.',
720
794
  your_key: agentKey,
721
- payment: {
722
- chain: 'solana',
723
- network: debug ? 'devnet' : 'mainnet-beta',
724
- token: 'USDC',
725
- amount: String(minPayment),
726
- wallet_address: walletAddress,
727
- memo: agentKey,
728
- },
795
+ payment: buildPaymentField({ network, minPayment, walletAddress, memo: agentKey, feeInfo }),
729
796
  }, { walletAddress, mint: usdcMint, minPayment, debug, agentKey, resource: url.pathname });
730
797
  }
731
798
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentpayments/edge",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "AgentPayments gate for Cloudflare Workers, Netlify Edge, and Vercel Edge Functions — charge AI agents USDC on Solana",
5
5
  "license": "MIT",
6
6
  "type": "module",