@teneo-protocol/sdk 3.1.5 → 3.3.0

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 (58) hide show
  1. package/README.md +32 -8
  2. package/dist/core/websocket-client.d.ts +5 -0
  3. package/dist/core/websocket-client.d.ts.map +1 -1
  4. package/dist/core/websocket-client.js +51 -37
  5. package/dist/core/websocket-client.js.map +1 -1
  6. package/dist/formatters/response-formatter.d.ts.map +1 -1
  7. package/dist/formatters/response-formatter.js +11 -1
  8. package/dist/formatters/response-formatter.js.map +1 -1
  9. package/dist/handlers/webhook-handler.d.ts.map +1 -1
  10. package/dist/handlers/webhook-handler.js +4 -2
  11. package/dist/handlers/webhook-handler.js.map +1 -1
  12. package/dist/index.d.ts +7 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +12 -3
  15. package/dist/index.js.map +1 -1
  16. package/dist/managers/message-router.d.ts +2 -0
  17. package/dist/managers/message-router.d.ts.map +1 -1
  18. package/dist/managers/message-router.js +9 -2
  19. package/dist/managers/message-router.js.map +1 -1
  20. package/dist/teneo-sdk.d.ts +7 -3
  21. package/dist/teneo-sdk.d.ts.map +1 -1
  22. package/dist/teneo-sdk.js +7 -2
  23. package/dist/teneo-sdk.js.map +1 -1
  24. package/dist/types/config.d.ts +39 -12
  25. package/dist/types/config.d.ts.map +1 -1
  26. package/dist/types/config.js +22 -0
  27. package/dist/types/config.js.map +1 -1
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/types/index.js +3 -2
  31. package/dist/types/index.js.map +1 -1
  32. package/dist/types/messages.d.ts +65 -6
  33. package/dist/types/messages.d.ts.map +1 -1
  34. package/dist/types/messages.js +29 -8
  35. package/dist/types/messages.js.map +1 -1
  36. package/dist/utils/erc20.d.ts +65 -0
  37. package/dist/utils/erc20.d.ts.map +1 -0
  38. package/dist/utils/erc20.js +116 -0
  39. package/dist/utils/erc20.js.map +1 -0
  40. package/dist/utils/index.d.ts +5 -0
  41. package/dist/utils/index.d.ts.map +1 -1
  42. package/dist/utils/index.js +9 -1
  43. package/dist/utils/index.js.map +1 -1
  44. package/examples/api-key-payment-flow.ts +103 -0
  45. package/package.json +1 -1
  46. package/src/core/websocket-client.ts +60 -39
  47. package/src/formatters/response-formatter.ts +11 -1
  48. package/src/handlers/webhook-handler.ts +4 -2
  49. package/src/index.ts +8 -1
  50. package/src/managers/message-router.ts +10 -2
  51. package/src/teneo-sdk.ts +8 -3
  52. package/src/types/config.ts +23 -0
  53. package/src/types/index.ts +1 -0
  54. package/src/types/messages.ts +38 -8
  55. package/src/utils/erc20.test.ts +161 -0
  56. package/src/utils/erc20.ts +125 -0
  57. package/src/utils/index.ts +10 -0
  58. package/tsconfig.json +2 -1
@@ -19,6 +19,7 @@ import {
19
19
  BaseMessageSchema,
20
20
  createRequestChallenge,
21
21
  createCheckCachedAuth,
22
+ createApiKeyAuth,
22
23
  createPing,
23
24
  safeParseMessage,
24
25
  Logger
@@ -606,7 +607,7 @@ export class WebSocketClient extends EventEmitter<SDKEvents> {
606
607
  * Authenticate with the server
607
608
  */
608
609
  private async authenticate(): Promise<void> {
609
- if (!this.account && !this.config.walletAddress) {
610
+ if (!this.account && !this.config.walletAddress && !this.config.apiKey) {
610
611
  this.logger.info("No authentication configured, continuing without auth");
611
612
  this.updateAuthState({ authenticated: false });
612
613
  this.emit("ready");
@@ -614,6 +615,22 @@ export class WebSocketClient extends EventEmitter<SDKEvents> {
614
615
  }
615
616
 
616
617
  try {
618
+ // API-key auth: single message, no challenge-response needed
619
+ if (this.config.apiKey) {
620
+ const address = this.account?.address || this.config.walletAddress;
621
+ if (!address) {
622
+ throw new AuthenticationError("walletAddress is required for API-key authentication");
623
+ }
624
+
625
+ this.logger.debug("Authenticating with API key");
626
+ await this.sendMessage(
627
+ createApiKeyAuth(address, this.config.apiKey, this.config.clientType || "user")
628
+ );
629
+
630
+ await this.waitForAuthCompletion();
631
+ return;
632
+ }
633
+
617
634
  // Check for cached authentication first
618
635
  if (this.config.walletAddress) {
619
636
  const sessionToken = this.authState.sessionToken;
@@ -631,7 +648,7 @@ export class WebSocketClient extends EventEmitter<SDKEvents> {
631
648
  }
632
649
  }
633
650
 
634
- // Request challenge
651
+ // Challenge-response auth (requires privateKey)
635
652
  this.logger.debug("Requesting authentication challenge");
636
653
  await this.sendMessage(
637
654
  createRequestChallenge(
@@ -641,49 +658,53 @@ export class WebSocketClient extends EventEmitter<SDKEvents> {
641
658
  );
642
659
 
643
660
  // Wait for authentication to complete
644
- await new Promise<void>((resolve, reject) => {
645
- let timeout: NodeJS.Timeout | undefined;
646
- const pollTimeouts: NodeJS.Timeout[] = [];
647
-
648
- // Centralized cleanup function - guarantees cleanup in all scenarios
649
- const cleanup = () => {
650
- if (timeout) {
651
- clearTimeout(timeout);
652
- timeout = undefined;
653
- }
654
- // Clear all polling timeouts
655
- pollTimeouts.forEach((t) => clearTimeout(t));
656
- pollTimeouts.length = 0;
657
- };
658
-
659
- // Set main authentication timeout
660
- timeout = setTimeout(() => {
661
- cleanup();
662
- reject(new AuthenticationError("Authentication timeout"));
663
- }, TIMEOUTS.AUTH_TIMEOUT);
664
-
665
- const checkAuth = () => {
666
- if (this.authState.authenticated) {
667
- cleanup();
668
- resolve();
669
- } else if (this.connectionState.lastError) {
670
- cleanup();
671
- reject(this.connectionState.lastError);
672
- } else {
673
- // Store polling timeout for cleanup
674
- const pollTimeout = setTimeout(checkAuth, TIMEOUTS.AUTH_POLL_INTERVAL);
675
- pollTimeouts.push(pollTimeout);
676
- }
677
- };
678
-
679
- checkAuth();
680
- });
661
+ await this.waitForAuthCompletion();
681
662
  } catch (error) {
682
663
  this.logger.error("Authentication failed", error);
683
664
  throw new AuthenticationError("Failed to authenticate", error);
684
665
  }
685
666
  }
686
667
 
668
+ /**
669
+ * Waits for authentication state to become authenticated.
670
+ * Used by both API-key and challenge-response auth flows.
671
+ */
672
+ private async waitForAuthCompletion(): Promise<void> {
673
+ await new Promise<void>((resolve, reject) => {
674
+ let timeout: NodeJS.Timeout | undefined;
675
+ const pollTimeouts: NodeJS.Timeout[] = [];
676
+
677
+ const cleanup = () => {
678
+ if (timeout) {
679
+ clearTimeout(timeout);
680
+ timeout = undefined;
681
+ }
682
+ pollTimeouts.forEach((t) => clearTimeout(t));
683
+ pollTimeouts.length = 0;
684
+ };
685
+
686
+ timeout = setTimeout(() => {
687
+ cleanup();
688
+ reject(new AuthenticationError("Authentication timeout"));
689
+ }, TIMEOUTS.AUTH_TIMEOUT);
690
+
691
+ const checkAuth = () => {
692
+ if (this.authState.authenticated) {
693
+ cleanup();
694
+ resolve();
695
+ } else if (this.connectionState.lastError) {
696
+ cleanup();
697
+ reject(this.connectionState.lastError);
698
+ } else {
699
+ const pollTimeout = setTimeout(checkAuth, TIMEOUTS.AUTH_POLL_INTERVAL);
700
+ pollTimeouts.push(pollTimeout);
701
+ }
702
+ };
703
+
704
+ checkAuth();
705
+ });
706
+ }
707
+
687
708
  /**
688
709
  * Create handler context with all dependencies
689
710
  */
@@ -456,7 +456,17 @@ export class ResponseFormatter {
456
456
  if (agent.commands && agent.commands.length > 0) {
457
457
  result += "\n Commands:";
458
458
  for (const cmd of agent.commands) {
459
- result += `\n - ${cmd.trigger}${cmd.argument ? ` ${cmd.argument}` : ""}`;
459
+ const paramUsage = cmd.parameters?.length && !cmd.argument
460
+ ? " " + cmd.parameters.map((p: any) => p.required !== false ? `<${p.name}>` : `[${p.name}]`).join(" ")
461
+ : cmd.argument ? ` ${cmd.argument}` : "";
462
+ result += `\n - ${cmd.trigger}${paramUsage}`;
463
+ if (cmd.parameters?.length) {
464
+ for (const p of cmd.parameters) {
465
+ const req = p.required !== false ? "required" : "optional";
466
+ const desc = p.description ? `: ${p.description}` : "";
467
+ result += `\n ${p.name} (${p.type || "string"}, ${req})${desc}`;
468
+ }
469
+ }
460
470
  }
461
471
  }
462
472
  }
@@ -280,8 +280,10 @@ export class WebhookHandler extends EventEmitter<SDKEvents> {
280
280
  await this.sendWebhook(eventType, validatedMessage, metadata);
281
281
  } catch (error) {
282
282
  if (error instanceof z.ZodError) {
283
- this.logger.error("Invalid message for webhook", error);
284
- throw new ValidationError("Invalid message for webhook", error);
283
+ this.logger.debug("Skipping webhook for unrecognized message type", {
284
+ type: message.type
285
+ });
286
+ return;
285
287
  }
286
288
  throw error;
287
289
  }
package/src/index.ts CHANGED
@@ -151,6 +151,7 @@ export {
151
151
  createRequestChallenge,
152
152
  createCheckCachedAuth,
153
153
  createAuth,
154
+ createApiKeyAuth,
154
155
  createUserMessage,
155
156
  createPing,
156
157
  createSubscribe,
@@ -233,6 +234,12 @@ export {
233
234
  */
234
235
  export { SecurePrivateKey } from "./utils/secure-private-key";
235
236
 
237
+ /**
238
+ * ERC20 utilities for wallet transaction flows
239
+ * Check allowances to skip unnecessary approval transactions
240
+ */
241
+ export { checkERC20Allowance, parseApproveCalldata, ERC20_APPROVE_SELECTOR } from "./utils/erc20";
242
+
236
243
  /**
237
244
  * Payment utilities for x402 payment signing
238
245
  * Enables SDK users to make payments directly using private keys
@@ -264,7 +271,7 @@ export {
264
271
  /**
265
272
  * SDK version string
266
273
  */
267
- export const VERSION = "3.0.1";
274
+ export const VERSION = "3.3.0";
268
275
 
269
276
  /**
270
277
  * Convenience type re-exports for message operations
@@ -86,6 +86,7 @@ export interface MessageRouterConfig {
86
86
  maxPricePerRequest?: number;
87
87
  quoteTimeout?: number;
88
88
  wsUrl?: string;
89
+ apiKey?: string; // API key for session-key payment flow
89
90
  paymentNetwork?: string; // CAIP-2 format (e.g., "eip155:3338")
90
91
  paymentAsset?: string;
91
92
  network?: string; // Network name (e.g., "peaq", "base", "avalanche")
@@ -107,6 +108,7 @@ export class MessageRouter extends EventEmitter<SDKEvents> {
107
108
  private readonly maxPricePerRequest?: number;
108
109
  private readonly quoteTimeout: number;
109
110
  private readonly wsUrl: string;
111
+ private readonly apiKey?: string; // API key for session-key payment
110
112
  private readonly paymentNetwork: string; // CAIP-2 format if set
111
113
  private readonly paymentAsset: string;
112
114
  private readonly networkName: string; // Network name (peaq, base, avalanche)
@@ -135,6 +137,7 @@ export class MessageRouter extends EventEmitter<SDKEvents> {
135
137
  this.maxPricePerRequest = config.maxPricePerRequest;
136
138
  this.quoteTimeout = config.quoteTimeout ?? 30000;
137
139
  this.wsUrl = config.wsUrl ?? "";
140
+ this.apiKey = config.apiKey;
138
141
 
139
142
  // Store config values - dynamic network resolution happens lazily in getPaymentNetwork/Asset()
140
143
  // because networks are only initialized after connect() is called
@@ -627,7 +630,7 @@ export class MessageRouter extends EventEmitter<SDKEvents> {
627
630
  let paymentHeader: string | undefined;
628
631
 
629
632
  // Create payment header if payment client is configured and price > 0
630
- if (this.paymentClient && quote.pricing.pricePerUnit > 0) {
633
+ if (this.paymentClient && quote.pricing.pricePerUnit > 0 && !this.apiKey) {
631
634
  try {
632
635
  // Pass backend-provided settlement data to payment header creation
633
636
  paymentHeader = await this.paymentClient.createPaymentHeader(
@@ -650,7 +653,12 @@ export class MessageRouter extends EventEmitter<SDKEvents> {
650
653
  }
651
654
  }
652
655
 
653
- const confirmMessage = createConfirmTask(taskId, paymentHeader);
656
+ // Build confirm message — x402 payment or API-key, whichever is available
657
+ const confirmMessage = createConfirmTask(taskId, {
658
+ x402Payment: paymentHeader,
659
+ apiKey: this.apiKey,
660
+ network: this.apiKey ? this.networkName || undefined : undefined
661
+ });
654
662
 
655
663
  this.logger.debug("MessageRouter: Confirming quote", { taskId });
656
664
 
package/src/teneo-sdk.ts CHANGED
@@ -211,6 +211,7 @@ export class TeneoSDK extends EventEmitter<SDKEvents> {
211
211
  maxPricePerRequest: this.config.maxPricePerRequest,
212
212
  quoteTimeout: this.config.quoteTimeout,
213
213
  wsUrl: this.config.wsUrl,
214
+ apiKey: this.config.apiKey,
214
215
  paymentNetwork: this.config.paymentNetwork,
215
216
  paymentAsset: this.config.paymentAsset,
216
217
  network: this.config.network, // Network name from withNetwork()
@@ -1344,8 +1345,8 @@ export class TeneoSDK extends EventEmitter<SDKEvents> {
1344
1345
  * when a chainId is provided, matching the format the UI uses.
1345
1346
  *
1346
1347
  * @param taskId - The task ID from the wallet:tx_requested event
1347
- * @param status - Transaction result: "confirmed", "rejected", or "failed"
1348
- * @param txHash - The on-chain transaction hash (required for "confirmed" status)
1348
+ * @param status - Transaction result: "broadcasted" (tx sent, hash available), "confirmed" (on-chain receipt), "rejected" (user declined), or "failed" (error)
1349
+ * @param txHash - The on-chain transaction hash (required for "broadcasted" and "confirmed" status)
1349
1350
  * @param error - Error message (optional, for "failed" status)
1350
1351
  * @param room - The room ID from the wallet:tx_requested event (required for routing)
1351
1352
  * @param chainId - The chain ID from data.tx.chainId (used to format txHash with network name)
@@ -1356,6 +1357,10 @@ export class TeneoSDK extends EventEmitter<SDKEvents> {
1356
1357
  * sdk.on("wallet:tx_requested", async (data) => {
1357
1358
  * try {
1358
1359
  * const txHash = await wallet.sendTransaction(data.tx);
1360
+ * // Notify agent that tx was broadcast (hash available, not yet confirmed)
1361
+ * await sdk.sendTxResult(data.taskId, "broadcasted", txHash, undefined, data.room, data.tx.chainId);
1362
+ * // Wait for on-chain confirmation
1363
+ * await waitForReceipt(txHash);
1359
1364
  * await sdk.sendTxResult(data.taskId, "confirmed", txHash, undefined, data.room, data.tx.chainId);
1360
1365
  * } catch (err) {
1361
1366
  * await sdk.sendTxResult(data.taskId, "failed", undefined, err.message, data.room, data.tx.chainId);
@@ -1365,7 +1370,7 @@ export class TeneoSDK extends EventEmitter<SDKEvents> {
1365
1370
  */
1366
1371
  public async sendTxResult(
1367
1372
  taskId: string,
1368
- status: "confirmed" | "rejected" | "failed",
1373
+ status: "broadcasted" | "confirmed" | "rejected" | "failed",
1369
1374
  txHash?: string,
1370
1375
  error?: string,
1371
1376
  room?: string,
@@ -86,6 +86,7 @@ const SDKConfigBaseSchema = z.object({
86
86
  // Authentication
87
87
  privateKey: PrivateKeySchema.optional(),
88
88
  walletAddress: z.string().optional(),
89
+ apiKey: z.string().optional(), // API key for session-key auth & payment (no privateKey needed)
89
90
 
90
91
  // Client identification
91
92
  clientType: ClientTypeSchema.optional(),
@@ -432,6 +433,28 @@ export class SDKConfigBuilder {
432
433
  return this;
433
434
  }
434
435
 
436
+ /**
437
+ * Sets the API key for session-key-based authentication and payment.
438
+ * When set, the SDK authenticates with the API key directly (no challenge-response)
439
+ * and uses it for task confirmations instead of x402 payment signing.
440
+ *
441
+ * @param apiKey - API key string (e.g., 'sk_live_...')
442
+ * @param walletAddress - Wallet address associated with the API key
443
+ * @returns this builder for method chaining
444
+ *
445
+ * @example
446
+ * ```typescript
447
+ * builder.withApiKey('sk_live_kQp3x...', '0xYourWalletAddress')
448
+ * ```
449
+ */
450
+ withApiKey(apiKey: string, walletAddress?: string): this {
451
+ this.config.apiKey = z.string().min(1).parse(apiKey);
452
+ if (walletAddress) {
453
+ this.config.walletAddress = z.string().parse(walletAddress);
454
+ }
455
+ return this;
456
+ }
457
+
435
458
  /**
436
459
  * Configures webhook URL and optional HTTP headers for receiving real-time event notifications.
437
460
  * Webhook URL must use HTTPS for non-localhost endpoints (security requirement).
@@ -221,6 +221,7 @@ export {
221
221
  createRequestChallenge,
222
222
  createCheckCachedAuth,
223
223
  createAuth,
224
+ createApiKeyAuth,
224
225
  createUserMessage,
225
226
  createPing,
226
227
  createSubscribe,
@@ -31,7 +31,7 @@ const stringToBoolean = z
31
31
  }
32
32
 
33
33
  // Accept falsy values
34
- if (normalized === "false" || normalized === "0" || normalized === "no") {
34
+ if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "skipped") {
35
35
  return false;
36
36
  }
37
37
 
@@ -135,7 +135,10 @@ export const MessageTypeSchema = z.enum([
135
135
 
136
136
  // Wallet Transaction Flow
137
137
  "trigger_wallet_tx",
138
- "tx_result"
138
+ "tx_result",
139
+
140
+ // Agent Owner Wallets
141
+ "agent_owner_wallets"
139
142
  ]);
140
143
 
141
144
  export const ContentTypeSchema = z.enum([
@@ -327,7 +330,10 @@ export const AuthMessageSchema = BaseMessageSchema.extend({
327
330
  jwt_token: z.string().optional(), // JWT token for KeyVault API authentication
328
331
  session_token: z.string().optional(), // 64-char hex token for fast re-auth (24h validity)
329
332
  whitelist_verified: z.union([z.boolean(), z.string()]).optional(), // Whitelist verification status
330
- user_count: z.number().optional() // Total user count (admin only)
333
+ user_count: z.number().optional(), // Total user count (admin only)
334
+ // API-key auth fields
335
+ api_key: z.string().optional(), // API key for session-key auth (no challenge-response needed)
336
+ platform: z.string().optional() // Platform identifier (e.g., "community")
331
337
  })
332
338
  .optional()
333
339
  });
@@ -504,7 +510,9 @@ export const TaskQuoteMessageSchema = BaseMessageSchema.extend({
504
510
  export const ConfirmTaskMessageSchema = BaseMessageSchema.extend({
505
511
  type: z.literal("confirm_task"),
506
512
  data: z.object({
507
- task_id: z.string()
513
+ task_id: z.string(),
514
+ api_key: z.string().optional(), // API key for session-key payment flow
515
+ network: z.string().optional() // Payment network: "peaq", "base", "avalanche", "x-layer"
508
516
  }),
509
517
  payment: z.string().optional() // x402 payment at top level (backend checks msg.Payment)
510
518
  });
@@ -1043,7 +1051,7 @@ export const AgentErrorMessageSchema = BaseMessageSchema.extend({
1043
1051
  export type AgentErrorMessage = z.infer<typeof AgentErrorMessageSchema>;
1044
1052
 
1045
1053
  // Wallet Transaction schemas
1046
- export const TxResultStatusSchema = z.enum(["confirmed", "rejected", "failed"]);
1054
+ export const TxResultStatusSchema = z.enum(["broadcasted", "confirmed", "rejected", "failed"]);
1047
1055
  export type TxResultStatus = z.infer<typeof TxResultStatusSchema>;
1048
1056
 
1049
1057
  export const TriggerWalletTxMessageSchema = BaseMessageSchema.extend({
@@ -1283,6 +1291,23 @@ export function createAuth(
1283
1291
  });
1284
1292
  }
1285
1293
 
1294
+ export function createApiKeyAuth(
1295
+ address: string,
1296
+ apiKey: string,
1297
+ userType: ClientType = "user",
1298
+ platform: string = "community"
1299
+ ): AuthMessage {
1300
+ return AuthMessageSchema.parse({
1301
+ type: "auth",
1302
+ data: {
1303
+ address,
1304
+ userType,
1305
+ api_key: apiKey,
1306
+ platform
1307
+ }
1308
+ });
1309
+ }
1310
+
1286
1311
  export function createUserMessage(content: string, room: string, from?: string): UserMessage {
1287
1312
  return UserMessageSchema.parse({
1288
1313
  type: "message",
@@ -1332,13 +1357,18 @@ export function createRequestTask(
1332
1357
  });
1333
1358
  }
1334
1359
 
1335
- export function createConfirmTask(taskId: string, x402Payment?: string): ConfirmTaskMessage {
1360
+ export function createConfirmTask(
1361
+ taskId: string,
1362
+ options?: { x402Payment?: string; apiKey?: string; network?: string }
1363
+ ): ConfirmTaskMessage {
1336
1364
  return ConfirmTaskMessageSchema.parse({
1337
1365
  type: "confirm_task",
1338
1366
  data: {
1339
- task_id: taskId
1367
+ task_id: taskId,
1368
+ ...(options?.apiKey && { api_key: options.apiKey }),
1369
+ ...(options?.network && { network: options.network })
1340
1370
  },
1341
- ...(x402Payment && { payment: x402Payment }) // payment at top level for backend
1371
+ ...(options?.x402Payment && { payment: options.x402Payment })
1342
1372
  });
1343
1373
  }
1344
1374
 
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { parseApproveCalldata, checkERC20Allowance, ERC20_APPROVE_SELECTOR } from "./erc20";
3
+
4
+ describe("parseApproveCalldata", () => {
5
+ // Valid approve calldata: approve(0x1234...5678, max_uint256)
6
+ const spender = "1234567890abcdef1234567890abcdef12345678";
7
+ const maxUint256Hex = "f".repeat(64);
8
+ const validCalldata = ERC20_APPROVE_SELECTOR +
9
+ spender.padStart(64, "0") +
10
+ maxUint256Hex;
11
+
12
+ it("should parse valid approve calldata", () => {
13
+ const result = parseApproveCalldata(validCalldata);
14
+ expect(result).not.toBeNull();
15
+ expect(result!.spender).toBe(`0x${spender}`);
16
+ expect(result!.amount).toBe(BigInt("0x" + maxUint256Hex));
17
+ });
18
+
19
+ it("should return null for undefined data", () => {
20
+ expect(parseApproveCalldata(undefined)).toBeNull();
21
+ });
22
+
23
+ it("should return null for empty string", () => {
24
+ expect(parseApproveCalldata("")).toBeNull();
25
+ });
26
+
27
+ it("should return null for short data", () => {
28
+ expect(parseApproveCalldata("0x095ea7b3")).toBeNull();
29
+ });
30
+
31
+ it("should return null for wrong selector", () => {
32
+ const wrongSelector = "0xa9059cbb" + "0".repeat(128); // transfer() selector
33
+ expect(parseApproveCalldata(wrongSelector)).toBeNull();
34
+ });
35
+
36
+ it("should return null for data shorter than 138 chars", () => {
37
+ const shortData = ERC20_APPROVE_SELECTOR + "0".repeat(60); // only 68 + 10 = 78 chars
38
+ expect(parseApproveCalldata(shortData)).toBeNull();
39
+ });
40
+
41
+ it("should handle uppercase hex", () => {
42
+ const upper = validCalldata.toUpperCase();
43
+ // selector check lowercases, so "0X" prefix won't match "0x"
44
+ const withLowerPrefix = "0x" + upper.slice(2);
45
+ const result = parseApproveCalldata(withLowerPrefix);
46
+ expect(result).not.toBeNull();
47
+ expect(result!.spender).toBe(`0x${spender.toUpperCase()}`);
48
+ });
49
+
50
+ it("should parse a specific amount correctly", () => {
51
+ const amount = "0000000000000000000000000000000000000000000000000de0b6b3a7640000"; // 1e18
52
+ const calldata = ERC20_APPROVE_SELECTOR + "0".repeat(24) + spender + amount;
53
+ const result = parseApproveCalldata(calldata);
54
+ expect(result).not.toBeNull();
55
+ expect(result!.amount).toBe(BigInt("0xde0b6b3a7640000")); // 1e18 in hex
56
+ });
57
+
58
+ it("should parse zero amount", () => {
59
+ const calldata = ERC20_APPROVE_SELECTOR + "0".repeat(24) + spender + "0".repeat(64);
60
+ const result = parseApproveCalldata(calldata);
61
+ expect(result).not.toBeNull();
62
+ expect(result!.amount).toBe(0n);
63
+ });
64
+ });
65
+
66
+ describe("checkERC20Allowance", () => {
67
+ const mockFetch = vi.fn();
68
+
69
+ beforeEach(() => {
70
+ vi.stubGlobal("fetch", mockFetch);
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.restoreAllMocks();
75
+ });
76
+
77
+ const rpcUrl = "https://rpc.example.com";
78
+ const tokenAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
79
+ const owner = "0x1111111111111111111111111111111111111111";
80
+ const spender = "0x2222222222222222222222222222222222222222";
81
+
82
+ it("should return allowance as bigint", async () => {
83
+ const allowanceHex = "0x00000000000000000000000000000000000000000000000000000000000f4240"; // 1000000
84
+ mockFetch.mockResolvedValueOnce({
85
+ ok: true,
86
+ json: () => Promise.resolve({ result: allowanceHex }),
87
+ });
88
+
89
+ const result = await checkERC20Allowance(rpcUrl, tokenAddress, owner, spender);
90
+ expect(result).toBe(1000000n);
91
+ });
92
+
93
+ it("should return 0n for zero allowance", async () => {
94
+ mockFetch.mockResolvedValueOnce({
95
+ ok: true,
96
+ json: () => Promise.resolve({ result: "0x" }),
97
+ });
98
+
99
+ const result = await checkERC20Allowance(rpcUrl, tokenAddress, owner, spender);
100
+ expect(result).toBe(0n);
101
+ });
102
+
103
+ it("should throw on RPC error response", async () => {
104
+ mockFetch.mockResolvedValueOnce({
105
+ ok: true,
106
+ json: () => Promise.resolve({ error: { message: "execution reverted" } }),
107
+ });
108
+
109
+ await expect(checkERC20Allowance(rpcUrl, tokenAddress, owner, spender))
110
+ .rejects.toThrow("RPC error: execution reverted");
111
+ });
112
+
113
+ it("should throw on HTTP error", async () => {
114
+ mockFetch.mockResolvedValueOnce({
115
+ ok: false,
116
+ status: 503,
117
+ });
118
+
119
+ await expect(checkERC20Allowance(rpcUrl, tokenAddress, owner, spender))
120
+ .rejects.toThrow("RPC request failed with status 503");
121
+ });
122
+
123
+ it("should throw on network failure", async () => {
124
+ mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
125
+
126
+ await expect(checkERC20Allowance(rpcUrl, tokenAddress, owner, spender))
127
+ .rejects.toThrow("fetch failed");
128
+ });
129
+
130
+ it("should send correct eth_call params", async () => {
131
+ mockFetch.mockResolvedValueOnce({
132
+ ok: true,
133
+ json: () => Promise.resolve({ result: "0x0" }),
134
+ });
135
+
136
+ await checkERC20Allowance(rpcUrl, tokenAddress, owner, spender);
137
+
138
+ expect(mockFetch).toHaveBeenCalledWith(rpcUrl, expect.objectContaining({
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ }));
142
+
143
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
144
+ expect(body.method).toBe("eth_call");
145
+ expect(body.params[0].to).toBe(tokenAddress);
146
+ // Should contain allowance selector 0xdd62ed3e
147
+ expect(body.params[0].data.startsWith("0xdd62ed3e")).toBe(true);
148
+ expect(body.params[1]).toBe("latest");
149
+ });
150
+
151
+ it("should handle max uint256 allowance", async () => {
152
+ const maxAllowance = "0x" + "f".repeat(64);
153
+ mockFetch.mockResolvedValueOnce({
154
+ ok: true,
155
+ json: () => Promise.resolve({ result: maxAllowance }),
156
+ });
157
+
158
+ const result = await checkERC20Allowance(rpcUrl, tokenAddress, owner, spender);
159
+ expect(result).toBe(BigInt(maxAllowance));
160
+ });
161
+ });