From 406da7a8be20e73bcefa6f1eb395f6062b5182ea Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Thu, 16 Oct 2025 17:41:20 -0700 Subject: [PATCH 01/33] Adding WIP code --- packages/client/src/PhantomClient.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 21695e1e..c2b74860 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -240,6 +240,24 @@ export class PhantomClient { addressFormat: networkConfig.addressFormat, }; + // POC: Pre-fetch organization data so we can inspect policies in the network tab + // This call is signed by the user's keypair via the axios stamper interceptor + try { + const orgRequest: any = { + method: "getOrganization", + params: { + organizationId: this.config.organizationId, + }, + timestampMs: await getSecureTimestamp(), + }; + // Fire and await to ensure it shows before signing + const orgResponse = await this.kmsApi.postKmsRpc(orgRequest); + console.log("Organization data (check for policies):", orgResponse.data); + } catch (e: any) { + // Do not block signing on org fetch errors in this POC + console.warn("getOrganization failed", e?.response?.data || e?.message || e); + } + // Sign transaction request - include configs if available const signRequest: SignTransactionRequest & { submissionConfig?: SubmissionConfig; From 0962b57365e3b0aad5852ad99dafe80bc7222a8d Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Thu, 16 Oct 2025 17:49:32 -0700 Subject: [PATCH 02/33] now using existing getOrg method, instead of reimplementing --- packages/client/src/PhantomClient.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index c2b74860..d81f46ac 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -241,21 +241,13 @@ export class PhantomClient { }; // POC: Pre-fetch organization data so we can inspect policies in the network tab - // This call is signed by the user's keypair via the axios stamper interceptor + // Use the existing public getOrganization method which handles auth properly try { - const orgRequest: any = { - method: "getOrganization", - params: { - organizationId: this.config.organizationId, - }, - timestampMs: await getSecureTimestamp(), - }; - // Fire and await to ensure it shows before signing - const orgResponse = await this.kmsApi.postKmsRpc(orgRequest); - console.log("Organization data (check for policies):", orgResponse.data); + const orgData = await this.getOrganization(this.config.organizationId); + console.log("Organization data (check for policies):", orgData); } catch (e: any) { // Do not block signing on org fetch errors in this POC - console.warn("getOrganization failed", e?.response?.data || e?.message || e); + console.warn("getOrganization failed", e?.message || e); } // Sign transaction request - include configs if available From 2959a2ed891b6e4a3a21f8788151bca224e565be Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Wed, 29 Oct 2025 21:36:35 -0700 Subject: [PATCH 03/33] SDK can query for lighthouse instructions from wallet service --- packages/client/src/PhantomClient.ts | 102 +++++++++++++++++++++++++-- packages/client/src/types.ts | 16 +++++ 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index d81f46ac..31359395 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -42,6 +42,7 @@ import { Buffer } from "buffer"; import { deriveSubmissionConfig } from "./caip2-mappings"; import { DerivationPath, getNetworkConfig } from "./constants"; import { + type AugmentWithSpendingLimitResponse, type AuthenticatorConfig, type CreateAuthenticatorParams, type CreateWalletResult, @@ -55,6 +56,7 @@ import { type SignMessageParams, type SignTransactionParams, type SignTypedDataParams, + type SpendingLimitConfig, type UserConfig, } from "./types"; @@ -195,6 +197,57 @@ export class PhantomClient { } } + /** + * Augments a transaction with spending limit enforcement instructions + * This is phase 1 of the two-phase spending limit flow + * @private + */ + private async augmentWithSpendingLimit( + transaction: string, + spendingLimitConfig: SpendingLimitConfig, + submissionConfig: SubmissionConfig, + account: string, + ): Promise { + try { + // Backend expects ChainTransaction enum: { Solana: "tx_string" } or { Evm: null } + const chainTransaction = submissionConfig.chain === "solana" ? { Solana: transaction } : { Evm: null }; + + const request = { + transaction: chainTransaction, + spendingLimitConfig, + submissionConfig, + simulationConfig: { account }, + }; + + const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/augment/spending-limit`, request, { + headers: { + "Content-Type": "application/json", + }, + }); + + return response.data; + } catch (error: any) { + console.error("Failed to augment transaction:", error.response?.data || error.message); + throw new Error(`Failed to augment transaction: ${error.response?.data?.message || error.message}`); + } + } + + /** + * Helper method to find user with spending limits in organization data + * @private + */ + private findUserWithSpendingLimits(orgData: ExternalKmsOrganization, walletId: string) { + return orgData.users?.find(user => { + const policy = (user as any).policy; + return ( + policy?.type === "CEL" && + policy?.cel?.preset === "DAPP_CONNECTION_USER" && + policy?.cel?.walletId === walletId && + policy?.cel?.usdLimit + ); + }); + } + /** * Private method for shared signing logic */ @@ -240,24 +293,54 @@ export class PhantomClient { addressFormat: networkConfig.addressFormat, }; - // POC: Pre-fetch organization data so we can inspect policies in the network tab - // Use the existing public getOrganization method which handles auth properly + // TWO-PHASE SPENDING LIMITS FLOW + // Phase 1: Check if user has spending limits and augment transaction if needed + let spendingLimitConfig: SpendingLimitConfig | undefined; + let augmentedTransaction = encodedTransaction; + try { const orgData = await this.getOrganization(this.config.organizationId); - console.log("Organization data (check for policies):", orgData); + const currentUser = this.findUserWithSpendingLimits(orgData, walletId); + const policy = (currentUser as any)?.policy; + + if (policy?.cel?.usdLimit) { + spendingLimitConfig = { + memoryAccount: policy.cel.usdLimit.memoryAccount, + memoryId: policy.cel.usdLimit.memoryId, + memoryBump: policy.cel.usdLimit.memoryBump, + }; + console.log("Found spending limit config for user:", spendingLimitConfig); + + // Phase 1: Call augmentation service if spending limits exist and we're submitting + if (spendingLimitConfig && includeSubmissionConfig && submissionConfig && params.account) { + console.log("Augmenting transaction with spending limits..."); + + const augmentResponse = await this.augmentWithSpendingLimit( + encodedTransaction, + spendingLimitConfig, + submissionConfig, + params.account, + ); + + augmentedTransaction = augmentResponse.transaction; + console.log("Transaction augmented with Lighthouse instructions"); + } + } } catch (e: any) { - // Do not block signing on org fetch errors in this POC - console.warn("getOrganization failed", e?.message || e); + console.warn("Failed to check/apply spending limits", e?.message || e); + // Continue without spending limits - don't block transaction } - // Sign transaction request - include configs if available + // Phase 2: Sign the (possibly augmented) transaction + // Use augmentedTransaction which will have Lighthouse instructions if spending limits exist const signRequest: SignTransactionRequest & { submissionConfig?: SubmissionConfig; simulationConfig?: SimulationConfig; + spendingLimitConfig?: SpendingLimitConfig; } = { organizationId: this.config.organizationId, walletId: walletId, - transaction: encodedTransaction as any, + transaction: augmentedTransaction, // Use augmented transaction derivationInfo: derivationInfo, }; @@ -273,6 +356,11 @@ export class PhantomClient { }; } + // Add spending limit config if available + if (spendingLimitConfig) { + signRequest.spendingLimitConfig = spendingLimitConfig; + } + const request: SignTransaction = { method: SignTransactionMethodEnum.signTransaction, params: signRequest, diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 1cb28e49..bd0a9963 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -123,3 +123,19 @@ export interface UserConfig { role?: "ADMIN" | "USER"; // Optional, defaults to 'ADMIN' authenticators: AuthenticatorConfig[]; } + +// ============================================================================ +// Spending Limits Types +// ============================================================================ + +export interface SpendingLimitConfig { + memoryAccount: string; + memoryId: number; + memoryBump: number; +} + +export interface AugmentWithSpendingLimitResponse { + transaction: string; // base64url encoded with Lighthouse instructions + simulationResult?: any; + memoryConfigUsed: SpendingLimitConfig; +} From 822a388f159d5f7df30ad62481d6672d786cd96c Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Thu, 30 Oct 2025 14:18:33 -0700 Subject: [PATCH 04/33] adding tests --- packages/client/src/PhantomClient.test.ts | 639 +++++++++++++++++++--- packages/client/src/PhantomClient.ts | 89 ++- 2 files changed, 634 insertions(+), 94 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 97d03d80..c56086ea 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -1,12 +1,14 @@ import { PhantomClient } from "./PhantomClient"; import type { UserConfig, CreateAuthenticatorParams, AuthenticatorConfig } from "./types"; +import { NetworkId } from "@phantom/constants"; +import axios from "axios"; // Mock axios to prevent actual HTTP requests jest.mock("axios"); describe("PhantomClient Name Length Validation", () => { let client: PhantomClient; - + beforeEach(() => { client = new PhantomClient({ apiBaseUrl: "https://api.phantom.app", @@ -32,37 +34,33 @@ describe("PhantomClient Name Length Validation", () => { describe("organization name validation", () => { it("should throw error for organization name exceeding 64 characters", async () => { const longOrgName = "a".repeat(65); // 65 characters - - await expect( - client.createOrganization(longOrgName, [validUserConfig]) - ).rejects.toThrow("Organization name cannot exceed 64 characters. Current length: 65"); + + await expect(client.createOrganization(longOrgName, [validUserConfig])).rejects.toThrow( + "Organization name cannot exceed 64 characters. Current length: 65", + ); }); it("should accept organization name with exactly 64 characters", async () => { const exactLengthOrgName = "a".repeat(64); // 64 characters - + // Mock the API call to avoid actual HTTP request - const mockPost = jest.spyOn((client as any).kmsApi, 'postKmsRpc').mockResolvedValue({ - data: { result: { organizationId: "test-org-id" } } + const mockPost = jest.spyOn((client as any).kmsApi, "postKmsRpc").mockResolvedValue({ + data: { result: { organizationId: "test-org-id" } }, }); - await expect( - client.createOrganization(exactLengthOrgName, [validUserConfig]) - ).resolves.toBeDefined(); + await expect(client.createOrganization(exactLengthOrgName, [validUserConfig])).resolves.toBeDefined(); expect(mockPost).toHaveBeenCalled(); }); it("should accept organization name under 64 characters", async () => { const shortOrgName = "short-org-name"; // < 64 characters - - const mockPost = jest.spyOn((client as any).kmsApi, 'postKmsRpc').mockResolvedValue({ - data: { result: { organizationId: "test-org-id" } } + + const mockPost = jest.spyOn((client as any).kmsApi, "postKmsRpc").mockResolvedValue({ + data: { result: { organizationId: "test-org-id" } }, }); - await expect( - client.createOrganization(shortOrgName, [validUserConfig]) - ).resolves.toBeDefined(); + await expect(client.createOrganization(shortOrgName, [validUserConfig])).resolves.toBeDefined(); expect(mockPost).toHaveBeenCalled(); }); @@ -75,10 +73,10 @@ describe("PhantomClient Name Length Validation", () => { ...validUserConfig, username: longUsername, }; - - await expect( - client.createOrganization("valid-org", [userConfigWithLongName]) - ).rejects.toThrow("Username name cannot exceed 64 characters. Current length: 65"); + + await expect(client.createOrganization("valid-org", [userConfigWithLongName])).rejects.toThrow( + "Username name cannot exceed 64 characters. Current length: 65", + ); }); it("should accept username with exactly 64 characters", async () => { @@ -87,14 +85,12 @@ describe("PhantomClient Name Length Validation", () => { ...validUserConfig, username: exactLengthUsername, }; - - const mockPost = jest.spyOn((client as any).kmsApi, 'postKmsRpc').mockResolvedValue({ - data: { result: { organizationId: "test-org-id" } } + + const mockPost = jest.spyOn((client as any).kmsApi, "postKmsRpc").mockResolvedValue({ + data: { result: { organizationId: "test-org-id" } }, }); - await expect( - client.createOrganization("valid-org", [userConfigWithExactName]) - ).resolves.toBeDefined(); + await expect(client.createOrganization("valid-org", [userConfigWithExactName])).resolves.toBeDefined(); expect(mockPost).toHaveBeenCalled(); }); @@ -114,10 +110,10 @@ describe("PhantomClient Name Length Validation", () => { } as AuthenticatorConfig, ], }; - - await expect( - client.createOrganization("valid-org", [userConfigWithLongAuth]) - ).rejects.toThrow("Authenticator name cannot exceed 64 characters. Current length: 65"); + + await expect(client.createOrganization("valid-org", [userConfigWithLongAuth])).rejects.toThrow( + "Authenticator name cannot exceed 64 characters. Current length: 65", + ); }); it("should accept authenticator name with exactly 64 characters", async () => { @@ -133,14 +129,12 @@ describe("PhantomClient Name Length Validation", () => { } as AuthenticatorConfig, ], }; - - const mockPost = jest.spyOn((client as any).kmsApi, 'postKmsRpc').mockResolvedValue({ - data: { result: { organizationId: "test-org-id" } } + + const mockPost = jest.spyOn((client as any).kmsApi, "postKmsRpc").mockResolvedValue({ + data: { result: { organizationId: "test-org-id" } }, }); - await expect( - client.createOrganization("valid-org", [userConfigWithExactAuth]) - ).resolves.toBeDefined(); + await expect(client.createOrganization("valid-org", [userConfigWithExactAuth])).resolves.toBeDefined(); expect(mockPost).toHaveBeenCalled(); }); @@ -166,10 +160,10 @@ describe("PhantomClient Name Length Validation", () => { ...validAuthParams, username: longUsername, }; - - await expect( - client.createAuthenticator(paramsWithLongUsername) - ).rejects.toThrow("Username name cannot exceed 64 characters. Current length: 65"); + + await expect(client.createAuthenticator(paramsWithLongUsername)).rejects.toThrow( + "Username name cannot exceed 64 characters. Current length: 65", + ); }); it("should throw error for authenticatorName exceeding 64 characters", async () => { @@ -178,10 +172,10 @@ describe("PhantomClient Name Length Validation", () => { ...validAuthParams, authenticatorName: longAuthName, }; - - await expect( - client.createAuthenticator(paramsWithLongAuthName) - ).rejects.toThrow("Authenticator name cannot exceed 64 characters. Current length: 65"); + + await expect(client.createAuthenticator(paramsWithLongAuthName)).rejects.toThrow( + "Authenticator name cannot exceed 64 characters. Current length: 65", + ); }); it("should throw error for authenticator.authenticatorName exceeding 64 characters", async () => { @@ -193,10 +187,10 @@ describe("PhantomClient Name Length Validation", () => { authenticatorName: longAuthName, }, }; - - await expect( - client.createAuthenticator(paramsWithLongNestedAuthName) - ).rejects.toThrow("Authenticator name cannot exceed 64 characters. Current length: 65"); + + await expect(client.createAuthenticator(paramsWithLongNestedAuthName)).rejects.toThrow( + "Authenticator name cannot exceed 64 characters. Current length: 65", + ); }); it("should accept all names with exactly 64 characters", async () => { @@ -210,14 +204,12 @@ describe("PhantomClient Name Length Validation", () => { authenticatorName: exactLengthName, }, }; - - const mockPost = jest.spyOn((client as any).kmsApi, 'postKmsRpc').mockResolvedValue({ - data: { result: { authenticatorId: "test-auth-id" } } + + const mockPost = jest.spyOn((client as any).kmsApi, "postKmsRpc").mockResolvedValue({ + data: { result: { authenticatorId: "test-auth-id" } }, }); - await expect( - client.createAuthenticator(paramsWithExactLengthNames) - ).resolves.toBeDefined(); + await expect(client.createAuthenticator(paramsWithExactLengthNames)).resolves.toBeDefined(); expect(mockPost).toHaveBeenCalled(); }); @@ -233,14 +225,12 @@ describe("PhantomClient Name Length Validation", () => { authenticatorName: shortName, }, }; - - const mockPost = jest.spyOn((client as any).kmsApi, 'postKmsRpc').mockResolvedValue({ - data: { result: { authenticatorId: "test-auth-id" } } + + const mockPost = jest.spyOn((client as any).kmsApi, "postKmsRpc").mockResolvedValue({ + data: { result: { authenticatorId: "test-auth-id" } }, }); - await expect( - client.createAuthenticator(paramsWithShortNames) - ).resolves.toBeDefined(); + await expect(client.createAuthenticator(paramsWithShortNames)).resolves.toBeDefined(); expect(mockPost).toHaveBeenCalled(); }); @@ -248,8 +238,8 @@ describe("PhantomClient Name Length Validation", () => { describe("edge cases", () => { it("should handle empty strings gracefully", async () => { - const mockPost = jest.spyOn((client as any).kmsApi, 'postKmsRpc').mockResolvedValue({ - data: { result: { organizationId: "test-org-id" } } + const mockPost = jest.spyOn((client as any).kmsApi, "postKmsRpc").mockResolvedValue({ + data: { result: { organizationId: "test-org-id" } }, }); const userConfigWithEmptyAuth: UserConfig = { @@ -266,9 +256,7 @@ describe("PhantomClient Name Length Validation", () => { }; // Empty strings should pass length validation (they're under 64 chars) - await expect( - client.createOrganization("valid-org", [userConfigWithEmptyAuth]) - ).resolves.toBeDefined(); + await expect(client.createOrganization("valid-org", [userConfigWithEmptyAuth])).resolves.toBeDefined(); expect(mockPost).toHaveBeenCalled(); }); @@ -287,7 +275,7 @@ describe("PhantomClient Name Length Validation", () => { } as AuthenticatorConfig, ], }; - + const invalidUser: UserConfig = { username: longUsername, // This should cause the error role: "ADMIN", @@ -301,9 +289,520 @@ describe("PhantomClient Name Length Validation", () => { ], }; + await expect(client.createOrganization("valid-org", [validUser, invalidUser])).rejects.toThrow( + "Username name cannot exceed 64 characters. Current length: 65", + ); + }); + }); +}); + +describe("PhantomClient Spending Limits Integration", () => { + let client: PhantomClient; + let mockAxiosPost: jest.Mock; + let mockKmsPost: jest.Mock; + let mockGetOrganization: jest.Mock; + + // Helper to create org data with spending limits + const createOrgDataWithSpendingLimits = (walletId: string) => ({ + users: [ + { + username: "spending-limit-user", + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId, + usdLimit: { + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, + }, + }, + ], + }); + + // Helper for org data without spending limits + const createOrgDataWithoutLimits = () => ({ + users: [ + { + username: "admin-user", + policy: { type: "ADMIN" }, + }, + ], + }); + + beforeEach(() => { + mockAxiosPost = jest.fn(); + const mockAxiosInstance = { + post: mockAxiosPost, + interceptors: { + request: { use: jest.fn() }, + }, + }; + + (axios.create as jest.Mock).mockReturnValue(mockAxiosInstance); + + client = new PhantomClient({ + apiBaseUrl: "https://api.phantom.app", + organizationId: "test-org-id", + headers: {}, + }); + + mockKmsPost = jest.fn(); + mockGetOrganization = jest.fn(); + + // Override private methods for testing + Object.defineProperty(client, "kmsApi", { + value: { postKmsRpc: mockKmsPost }, + writable: true, + }); + Object.defineProperty(client, "getOrganization", { + value: mockGetOrganization, + writable: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("augmentWithSpendingLimit method", () => { + const spendingConfig = { + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }; + + const solanaSubmissionConfig = { + chain: "solana" as const, + network: "mainnet", + }; + + it("should call augment endpoint with correct request structure", async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { + transaction: "augmented-tx", + simulationResult: { aggregated: { totalSpendUsd: 1.5 } }, + memoryConfigUsed: spendingConfig, + }, + }); + + const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + const result = await augmentMethod( + "original-tx-base64", + spendingConfig, + solanaSubmissionConfig, + "UserAccount123", + ); + + expect(result.transaction).toBe("augmented-tx"); + expect(mockAxiosPost).toHaveBeenCalledWith( + "https://api.phantom.app/augment/spending-limit", + { + transaction: { Solana: "original-tx-base64" }, + spendingLimitConfig: spendingConfig, + submissionConfig: solanaSubmissionConfig, + simulationConfig: { account: "UserAccount123" }, + }, + { headers: { "Content-Type": "application/json" } }, + ); + }); + + it("should throw error when augment endpoint fails", async () => { + mockAxiosPost.mockRejectedValueOnce({ + response: { + data: { + message: "Invalid transaction format", + }, + }, + }); + + const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + + await expect(augmentMethod("bad-tx", spendingConfig, solanaSubmissionConfig, "UserAccount123")).rejects.toThrow( + "Failed to augment transaction", + ); + }); + }); + + describe("findUserWithSpendingLimits", () => { + const findUserMethod = (orgData: any, walletId: string) => { + return client["findUserWithSpendingLimits"](orgData, walletId); + }; + + it("should correctly identify user with CEL policy and usdLimit", () => { + const orgData = createOrgDataWithSpendingLimits("wallet-123"); + const user = findUserMethod(orgData, "wallet-123"); + + expect(user).toBeDefined(); + expect(user?.username).toBe("spending-limit-user"); + expect(user?.policy?.cel?.usdLimit).toBeDefined(); + expect(user?.policy?.cel?.usdLimit?.memoryAccount).toBe("MemAcc123"); + }); + + describe.each([ + { + testName: "no user has spending limits", + orgData: createOrgDataWithoutLimits(), + walletId: "wallet-123", + }, + { + testName: "wallet ID doesn't match", + orgData: createOrgDataWithSpendingLimits("wallet-456"), + walletId: "wallet-123", + }, + { + testName: "policy is not CEL type", + orgData: { + users: [ + { + username: "user", + policy: { + type: "ADMIN", + cel: { walletId: "wallet-123", usdLimit: {} }, + }, + }, + ], + }, + walletId: "wallet-123", + }, + { + testName: "preset is not DAPP_CONNECTION_USER", + orgData: { + users: [ + { + username: "user", + policy: { + type: "CEL", + cel: { + preset: "OTHER_PRESET", + walletId: "wallet-123", + usdLimit: {}, + }, + }, + }, + ], + }, + walletId: "wallet-123", + }, + { + testName: "usdLimit is missing", + orgData: { + users: [ + { + username: "user", + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + }, + }, + }, + ], + }, + walletId: "wallet-123", + }, + { + testName: "usdLimit is null", + orgData: { + users: [ + { + username: "user", + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: null, + }, + }, + }, + ], + }, + walletId: "wallet-123", + }, + { + testName: "usdLimit properties are missing", + orgData: { + users: [ + { + username: "user", + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: { + memoryAccount: "MemAcc123", + // Missing memoryId and memoryBump + }, + }, + }, + }, + ], + }, + walletId: "wallet-123", + }, + ])("should return undefined when $testName", ({ orgData, walletId }) => { + it(`${walletId}`, () => { + const user = findUserMethod(orgData, walletId); + expect(user).toBeUndefined(); + }); + }); + }); + + describe("conditions for calling augment endpoint", () => { + const performSigning = (params: any, includeSubmissionConfig: boolean) => { + return client["performTransactionSigning"](params, includeSubmissionConfig); + }; + + describe.each([ + { + testName: "user has no spending limits", + orgData: createOrgDataWithoutLimits(), + params: { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + }, + includeSubmissionConfig: true, + }, + { + testName: "includeSubmissionConfig is false", + orgData: createOrgDataWithSpendingLimits("wallet-123"), + params: { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + }, + includeSubmissionConfig: false, + }, + { + testName: "account parameter is missing", + orgData: createOrgDataWithSpendingLimits("wallet-123"), + params: { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET }, + includeSubmissionConfig: true, + }, + { + testName: "transaction is EVM (not Solana)", + orgData: createOrgDataWithSpendingLimits("wallet-123"), + params: { + walletId: "wallet-123", + transaction: "0x1234", + networkId: NetworkId.ETHEREUM_MAINNET, + account: "0xUser", + }, + includeSubmissionConfig: true, + }, + ])("should NOT call augment when $testName", ({ orgData, params, includeSubmissionConfig }) => { + it(`${params.networkId}`, async () => { + mockGetOrganization.mockResolvedValue(orgData); + mockKmsPost.mockResolvedValue({ + data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, + }); + + await performSigning(params, includeSubmissionConfig); + + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); + }); + }); + + describe("error handling", () => { + it("should fail when augmentation fails for user with spending limits", async () => { + mockGetOrganization.mockResolvedValue(createOrgDataWithSpendingLimits("wallet-123")); + + mockAxiosPost.mockRejectedValueOnce(new Error("Augmentation service unavailable")); + + const performSigning = client["performTransactionSigning"].bind(client); + await expect( - client.createOrganization("valid-org", [validUser, invalidUser]) - ).rejects.toThrow("Username name cannot exceed 64 characters. Current length: 65"); + performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + true, + ), + ).rejects.toThrow("Failed to apply spending limits for this transaction"); + }); + + it("should continue signing when getOrganization fails", async () => { + mockGetOrganization.mockRejectedValue(new Error("Failed to fetch organization")); + + mockKmsPost.mockResolvedValueOnce({ + data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, + }); + + const performSigning = client["performTransactionSigning"].bind(client); + const result = await performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + true, + ); + + expect(result.signedTransaction).toBe("signed-tx"); + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); + }); + + describe("uses augmented transaction for signing", () => { + it("should use augmented transaction returned from augment endpoint", async () => { + const spendingConfig = { + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }; + + const submissionConfig = { + chain: "solana" as const, + network: "mainnet", + }; + + mockAxiosPost.mockResolvedValueOnce({ + data: { + transaction: "augmented-tx-with-lighthouse-instructions", + simulationResult: {}, + memoryConfigUsed: {}, + }, + }); + + const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + const result = await augmentMethod("original-tx", spendingConfig, submissionConfig, "UserAccount123"); + + expect(result.transaction).toBe("augmented-tx-with-lighthouse-instructions"); + }); + + it("should include spending limit config in sign request when present", async () => { + mockGetOrganization.mockResolvedValue(createOrgDataWithSpendingLimits("wallet-123")); + + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, + }); + + mockKmsPost.mockResolvedValueOnce({ + data: { result: { transaction: "signed-tx" } }, + }); + + const performSigning = client["performTransactionSigning"].bind(client); + await performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + true, + ); + + expect(mockKmsPost).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + spendingLimitConfig: { + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }), + }), + ); + }); + + it("should NOT include spending limit config when user has no limits", async () => { + mockGetOrganization.mockResolvedValue(createOrgDataWithoutLimits()); + + mockKmsPost.mockResolvedValueOnce({ + data: { result: { transaction: "signed-tx" } }, + }); + + const performSigning = client["performTransactionSigning"].bind(client); + await performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + true, + ); + + expect(mockKmsPost).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.not.objectContaining({ + spendingLimitConfig: expect.anything(), + }), + }), + ); + }); + }); + + describe("augment endpoint request structure", () => { + const spendingConfig = { + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }; + + it("should send Solana transactions in ChainTransaction format", async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, + }); + + const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + const result = await augmentMethod( + "solana-tx-base64", + spendingConfig, + { chain: "solana", network: "mainnet" }, + "UserAccount123", + ); + + expect(result.transaction).toBe("augmented-tx"); + expect(mockAxiosPost).toHaveBeenCalledWith( + "https://api.phantom.app/augment/spending-limit", + expect.objectContaining({ + transaction: { Solana: "solana-tx-base64" }, + }), + expect.any(Object), + ); + }); + + it("should send EVM transactions in ChainTransaction format", async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, + }); + + const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + const result = await augmentMethod( + "evm-tx", + spendingConfig, + { chain: "ethereum", network: "mainnet" }, + "0xUserAccount", + ); + + expect(result.transaction).toBe("augmented-tx"); + expect(mockAxiosPost).toHaveBeenCalledWith( + "https://api.phantom.app/augment/spending-limit", + expect.objectContaining({ + transaction: { Evm: null }, + }), + expect.any(Object), + ); + }); + + it("should include all required fields in augment request", async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, + }); + + const submissionConfig = { + chain: "solana" as const, + network: "mainnet", + }; + + const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + await augmentMethod("tx-base64", spendingConfig, submissionConfig, "UserAccount123"); + + expect(mockAxiosPost).toHaveBeenCalledWith( + "https://api.phantom.app/augment/spending-limit", + { + transaction: { Solana: "tx-base64" }, + spendingLimitConfig: spendingConfig, + submissionConfig, + simulationConfig: { account: "UserAccount123" }, + }, + { headers: { "Content-Type": "application/json" } }, + ); }); }); -}); \ No newline at end of file +}); diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index f844b399..0b45eaa7 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -62,11 +62,28 @@ import { import type { Stamper } from "@phantom/sdk-types"; import { getSecureTimestamp, randomUUID, isEthereumChain } from "@phantom/utils"; + type AddUserToOrganizationParams = Omit & { replaceExpirable?: boolean; user: PartialKmsUser & { traits: { appId: string }; expiresInMs?: number }; }; +// Type for a user with spending limits enforced via CEL policy +type UserWithSpendingLimits = PartialKmsUser & { + policy: { + type: "CEL"; + cel: { + preset: "DAPP_CONNECTION_USER"; + walletId: string; + usdLimit: { + memoryAccount: string; + memoryId: number; + memoryBump: number; + }; + }; + }; +}; + // TODO(napas): Auto generate this from the OpenAPI spec export interface SubmissionConfig { chain: string; // e.g., 'solana', 'ethereum', 'polygon' @@ -236,16 +253,33 @@ export class PhantomClient { * Helper method to find user with spending limits in organization data * @private */ - private findUserWithSpendingLimits(orgData: ExternalKmsOrganization, walletId: string) { - return orgData.users?.find(user => { + private findUserWithSpendingLimits( + orgData: ExternalKmsOrganization, + walletId: string, + ): UserWithSpendingLimits | undefined { + if (!orgData.users) return undefined; + + for (const user of orgData.users) { + // Check if user has policy (PartialKmsUser may not have policy in type definition) + if (!("policy" in user)) continue; + const policy = (user as any).policy; - return ( + + // Type guard: check all required properties + if ( policy?.type === "CEL" && policy?.cel?.preset === "DAPP_CONNECTION_USER" && policy?.cel?.walletId === walletId && - policy?.cel?.usdLimit - ); - }); + policy?.cel?.usdLimit && + typeof policy.cel.usdLimit.memoryAccount === "string" && + typeof policy.cel.usdLimit.memoryId === "number" && + typeof policy.cel.usdLimit.memoryBump === "number" + ) { + return user as UserWithSpendingLimits; + } + } + + return undefined; } /** @@ -301,21 +335,20 @@ export class PhantomClient { let spendingLimitConfig: SpendingLimitConfig | undefined; let augmentedTransaction = encodedTransaction; - try { - const orgData = await this.getOrganization(this.config.organizationId); - const currentUser = this.findUserWithSpendingLimits(orgData, walletId); - const policy = (currentUser as any)?.policy; - - if (policy?.cel?.usdLimit) { - spendingLimitConfig = { - memoryAccount: policy.cel.usdLimit.memoryAccount, - memoryId: policy.cel.usdLimit.memoryId, - memoryBump: policy.cel.usdLimit.memoryBump, - }; - console.log("Found spending limit config for user:", spendingLimitConfig); - - // Phase 1: Call augmentation service if spending limits exist and we're submitting - if (spendingLimitConfig && includeSubmissionConfig && submissionConfig && params.account) { + // Only check spending limits for Solana transactions + if (!isEvmTransaction && includeSubmissionConfig && submissionConfig && params.account) { + try { + const orgData = await this.getOrganization(this.config.organizationId); + const currentUser = this.findUserWithSpendingLimits(orgData, walletId); + + if (currentUser) { + spendingLimitConfig = { + memoryAccount: currentUser.policy.cel.usdLimit.memoryAccount, + memoryId: currentUser.policy.cel.usdLimit.memoryId, + memoryBump: currentUser.policy.cel.usdLimit.memoryBump, + }; + console.log("Found spending limit config for user:", spendingLimitConfig); + console.log("Augmenting transaction with spending limits..."); const augmentResponse = await this.augmentWithSpendingLimit( @@ -328,10 +361,18 @@ export class PhantomClient { augmentedTransaction = augmentResponse.transaction; console.log("Transaction augmented with Lighthouse instructions"); } + } catch (e: any) { + // If user has no spending limits, continue normally + // But if augmentation failed for a user WITH limits, throw error + if (spendingLimitConfig) { + throw new Error( + `Failed to apply spending limits for this transaction: ${e?.message || e}. ` + + `Transaction cannot proceed without spending limit enforcement.`, + ); + } + // Otherwise just log and continue (user doesn't have spending limits) + console.warn("Failed to check spending limits", e?.message || e); } - } catch (e: any) { - console.warn("Failed to check/apply spending limits", e?.message || e); - // Continue without spending limits - don't block transaction } // Phase 2: Sign the (possibly augmented) transaction From 8c6fa658d89aa2ba00290bb72864ba1a40c16752 Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Thu, 30 Oct 2025 14:36:56 -0700 Subject: [PATCH 05/33] Cleaning code --- packages/client/src/PhantomClient.test.ts | 87 ++++++++++++++--------- packages/client/src/PhantomClient.ts | 82 ++++++++++----------- 2 files changed, 91 insertions(+), 78 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index c56086ea..57401a20 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -427,19 +427,21 @@ describe("PhantomClient Spending Limits Integration", () => { }); }); - describe("findUserWithSpendingLimits", () => { - const findUserMethod = (orgData: any, walletId: string) => { - return client["findUserWithSpendingLimits"](orgData, walletId); + describe("checkUserSpendingLimit", () => { + const checkSpendingLimit = (orgData: any, walletId: string) => { + return client["checkUserSpendingLimit"](orgData, walletId); }; - it("should correctly identify user with CEL policy and usdLimit", () => { + it("should return spending limit config when user has limits", () => { const orgData = createOrgDataWithSpendingLimits("wallet-123"); - const user = findUserMethod(orgData, "wallet-123"); - - expect(user).toBeDefined(); - expect(user?.username).toBe("spending-limit-user"); - expect(user?.policy?.cel?.usdLimit).toBeDefined(); - expect(user?.policy?.cel?.usdLimit?.memoryAccount).toBe("MemAcc123"); + const result = checkSpendingLimit(orgData, "wallet-123"); + + expect(result.hasSpendingLimit).toBe(true); + if (result.hasSpendingLimit) { + expect(result.config.memoryAccount).toBe("MemAcc123"); + expect(result.config.memoryId).toBe(0); + expect(result.config.memoryBump).toBe(255); + } }); describe.each([ @@ -546,10 +548,10 @@ describe("PhantomClient Spending Limits Integration", () => { }, walletId: "wallet-123", }, - ])("should return undefined when $testName", ({ orgData, walletId }) => { + ])("should return hasSpendingLimit false when $testName", ({ orgData, walletId }) => { it(`${walletId}`, () => { - const user = findUserMethod(orgData, walletId); - expect(user).toBeUndefined(); + const result = checkSpendingLimit(orgData, walletId); + expect(result.hasSpendingLimit).toBe(false); }); }); }); @@ -629,7 +631,20 @@ describe("PhantomClient Spending Limits Integration", () => { ).rejects.toThrow("Failed to apply spending limits for this transaction"); }); - it("should continue signing when getOrganization fails", async () => { + it("should fail when getOrganization fails for Solana transaction", async () => { + mockGetOrganization.mockRejectedValue(new Error("API connection timeout")); + + const performSigning = client["performTransactionSigning"].bind(client); + + await expect( + performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + true, + ), + ).rejects.toThrow("Failed to fetch organization data for spending limit validation"); + }); + + it("should continue signing when getOrganization fails for EVM transaction", async () => { mockGetOrganization.mockRejectedValue(new Error("Failed to fetch organization")); mockKmsPost.mockResolvedValueOnce({ @@ -638,12 +653,27 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); const result = await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + { walletId: "wallet-123", transaction: "0x1234", networkId: NetworkId.ETHEREUM_MAINNET, account: "0xUser" }, true, ); expect(result.signedTransaction).toBe("signed-tx"); - expect(mockAxiosPost).not.toHaveBeenCalled(); + expect(mockGetOrganization).not.toHaveBeenCalled(); + }); + + it("should continue signing when includeSubmissionConfig is false (no org fetch needed)", async () => { + mockKmsPost.mockResolvedValueOnce({ + data: { result: { transaction: "signed-tx" } }, + }); + + const performSigning = client["performTransactionSigning"].bind(client); + const result = await performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET }, + false, + ); + + expect(result.signedTransaction).toBe("signed-tx"); + expect(mockGetOrganization).not.toHaveBeenCalled(); }); }); @@ -757,27 +787,14 @@ describe("PhantomClient Spending Limits Integration", () => { ); }); - it("should send EVM transactions in ChainTransaction format", async () => { - mockAxiosPost.mockResolvedValueOnce({ - data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, - }); - + it("should reject EVM transactions with clear error", async () => { const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - const result = await augmentMethod( - "evm-tx", - spendingConfig, - { chain: "ethereum", network: "mainnet" }, - "0xUserAccount", - ); - expect(result.transaction).toBe("augmented-tx"); - expect(mockAxiosPost).toHaveBeenCalledWith( - "https://api.phantom.app/augment/spending-limit", - expect.objectContaining({ - transaction: { Evm: null }, - }), - expect.any(Object), - ); + await expect( + augmentMethod("evm-tx", spendingConfig, { chain: "ethereum", network: "mainnet" }, "0xUserAccount"), + ).rejects.toThrow("Spending limits are only supported for Solana transactions"); + + expect(mockAxiosPost).not.toHaveBeenCalled(); }); it("should include all required fields in augment request", async () => { diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 0b45eaa7..4193e41c 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -68,21 +68,8 @@ type AddUserToOrganizationParams = Omit & user: PartialKmsUser & { traits: { appId: string }; expiresInMs?: number }; }; -// Type for a user with spending limits enforced via CEL policy -type UserWithSpendingLimits = PartialKmsUser & { - policy: { - type: "CEL"; - cel: { - preset: "DAPP_CONNECTION_USER"; - walletId: string; - usdLimit: { - memoryAccount: string; - memoryId: number; - memoryBump: number; - }; - }; - }; -}; +// Type for spending limit check result +type SpendingLimitCheckResult = { hasSpendingLimit: true; config: SpendingLimitConfig } | { hasSpendingLimit: false }; // TODO(napas): Auto generate this from the OpenAPI spec export interface SubmissionConfig { @@ -225,9 +212,13 @@ export class PhantomClient { submissionConfig: SubmissionConfig, account: string, ): Promise { + // This should never happen since we have this check above + if (submissionConfig.chain !== "solana") { + throw new Error("Spending limits are only supported for Solana transactions"); + } + try { - // Backend expects ChainTransaction enum: { Solana: "tx_string" } or { Evm: null } - const chainTransaction = submissionConfig.chain === "solana" ? { Solana: transaction } : { Evm: null }; + const chainTransaction = { Solana: transaction }; const request = { transaction: chainTransaction, @@ -250,22 +241,20 @@ export class PhantomClient { } /** - * Helper method to find user with spending limits in organization data + * Check if user has spending limits configured * @private */ - private findUserWithSpendingLimits( - orgData: ExternalKmsOrganization, - walletId: string, - ): UserWithSpendingLimits | undefined { - if (!orgData.users) return undefined; + private checkUserSpendingLimit(orgData: ExternalKmsOrganization, walletId: string): SpendingLimitCheckResult { + if (!orgData.users) { + return { hasSpendingLimit: false }; + } for (const user of orgData.users) { - // Check if user has policy (PartialKmsUser may not have policy in type definition) if (!("policy" in user)) continue; const policy = (user as any).policy; - // Type guard: check all required properties + // Check if this user matches the wallet and has valid spending limits if ( policy?.type === "CEL" && policy?.cel?.preset === "DAPP_CONNECTION_USER" && @@ -275,11 +264,18 @@ export class PhantomClient { typeof policy.cel.usdLimit.memoryId === "number" && typeof policy.cel.usdLimit.memoryBump === "number" ) { - return user as UserWithSpendingLimits; + return { + hasSpendingLimit: true, + config: { + memoryAccount: policy.cel.usdLimit.memoryAccount, + memoryId: policy.cel.usdLimit.memoryId, + memoryBump: policy.cel.usdLimit.memoryBump, + }, + }; } } - return undefined; + return { hasSpendingLimit: false }; } /** @@ -337,18 +333,24 @@ export class PhantomClient { // Only check spending limits for Solana transactions if (!isEvmTransaction && includeSubmissionConfig && submissionConfig && params.account) { + let orgData: ExternalKmsOrganization; + try { - const orgData = await this.getOrganization(this.config.organizationId); - const currentUser = this.findUserWithSpendingLimits(orgData, walletId); + orgData = await this.getOrganization(this.config.organizationId); + } catch (e: any) { + throw new Error( + `Failed to fetch organization data for spending limit validation: ${e?.message || e}. ` + + `Cannot proceed with transaction without verifying spending limits.`, + ); + } - if (currentUser) { - spendingLimitConfig = { - memoryAccount: currentUser.policy.cel.usdLimit.memoryAccount, - memoryId: currentUser.policy.cel.usdLimit.memoryId, - memoryBump: currentUser.policy.cel.usdLimit.memoryBump, - }; - console.log("Found spending limit config for user:", spendingLimitConfig); + const spendingLimitCheck = this.checkUserSpendingLimit(orgData, walletId); + if (spendingLimitCheck.hasSpendingLimit) { + spendingLimitConfig = spendingLimitCheck.config; + console.log("Found spending limit config for user:", spendingLimitConfig); + + try { console.log("Augmenting transaction with spending limits..."); const augmentResponse = await this.augmentWithSpendingLimit( @@ -360,18 +362,12 @@ export class PhantomClient { augmentedTransaction = augmentResponse.transaction; console.log("Transaction augmented with Lighthouse instructions"); - } - } catch (e: any) { - // If user has no spending limits, continue normally - // But if augmentation failed for a user WITH limits, throw error - if (spendingLimitConfig) { + } catch (e: any) { throw new Error( `Failed to apply spending limits for this transaction: ${e?.message || e}. ` + `Transaction cannot proceed without spending limit enforcement.`, ); } - // Otherwise just log and continue (user doesn't have spending limits) - console.warn("Failed to check spending limits", e?.message || e); } } From d86e93a95938029369f5d793ad11cec851018d74 Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Sat, 1 Nov 2025 12:22:25 -0700 Subject: [PATCH 06/33] Now getting the correct user from the org based on authenticators --- packages/client/src/PhantomClient.test.ts | 243 +++++++++++++++++++- packages/client/src/PhantomClient.ts | 265 ++++++++++++++++++++-- packages/client/src/types.ts | 1 + 3 files changed, 480 insertions(+), 29 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 57401a20..6b77241e 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -307,12 +307,14 @@ describe("PhantomClient Spending Limits Integration", () => { users: [ { username: "spending-limit-user", + authenticators: [{ publicKey: "default-test-public-key" }], policy: { type: "CEL", cel: { preset: "DAPP_CONNECTION_USER", walletId, usdLimit: { + usdCentsLimitPerDay: 1000, // $10.00 per day memoryAccount: "MemAcc123", memoryId: 0, memoryBump: 255, @@ -328,6 +330,7 @@ describe("PhantomClient Spending Limits Integration", () => { users: [ { username: "admin-user", + authenticators: [{ publicKey: "admin-public-key" }], policy: { type: "ADMIN" }, }, ], @@ -370,6 +373,7 @@ describe("PhantomClient Spending Limits Integration", () => { describe("augmentWithSpendingLimit method", () => { const spendingConfig = { + usdCentsLimitPerDay: 1000, // $10.00 per day memoryAccount: "MemAcc123", memoryId: 0, memoryBump: 255, @@ -401,7 +405,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/augment/spending-limit", { - transaction: { Solana: "original-tx-base64" }, + transaction: { solana: "original-tx-base64" }, spendingLimitConfig: spendingConfig, submissionConfig: solanaSubmissionConfig, simulationConfig: { account: "UserAccount123" }, @@ -432,16 +436,95 @@ describe("PhantomClient Spending Limits Integration", () => { return client["checkUserSpendingLimit"](orgData, walletId); }; - it("should return spending limit config when user has limits", () => { - const orgData = createOrgDataWithSpendingLimits("wallet-123"); + it("should return spending limit config when user has limits (nested format)", () => { + // Real base58/base64url key pair from actual user data (user_7EYmfEfp...) + const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; // Base58 from stamper + const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; // Base64url from API + + const orgData = { + users: [ + { + username: "spending-limit-user", + authenticators: [{ publicKey: mockBase64urlKey }], + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: { + usdCentsLimitPerDay: 1000, + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, + }, + }, + ], + }; + + // Mock stamper with getKeyInfo to return base58 encoded public key (as real stampers do) + // The code will convert this to base64url for comparison + (client as any).stamper = { + getKeyInfo: () => ({ publicKey: mockBase58Key }), + }; + const result = checkSpendingLimit(orgData, "wallet-123"); expect(result.hasSpendingLimit).toBe(true); if (result.hasSpendingLimit) { + expect(result.config.usdCentsLimitPerDay).toBe(1000); expect(result.config.memoryAccount).toBe("MemAcc123"); expect(result.config.memoryId).toBe(0); expect(result.config.memoryBump).toBe(255); } + + // Cleanup + (client as any).stamper = undefined; + }); + + it("should return spending limit config when user has limits (flat format - actual API)", () => { + // Use same real base58/base64url key pair as first test + const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; + const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; + + const orgData = { + users: [ + { + username: "spending-limit-user", + authenticators: [{ publicKey: mockBase64urlKey }], + policy: { + type: "CEL", + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: { + usdCentsLimitPerDay: 500, // $5.00 per day + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, + }, + ], + }; + + // Mock stamper with getKeyInfo to return base58 encoded public key (as real stampers do) + (client as any).stamper = { + getKeyInfo: () => ({ publicKey: mockBase58Key }), + }; + + const result = checkSpendingLimit(orgData, "wallet-123"); + + expect(result.hasSpendingLimit).toBe(true); + if (result.hasSpendingLimit) { + expect(result.config.usdCentsLimitPerDay).toBe(500); + expect(result.config.memoryAccount).toBe("MemAcc123"); + expect(result.config.memoryId).toBe(0); + expect(result.config.memoryBump).toBe(255); + } + + // Cleanup + (client as any).stamper = undefined; }); describe.each([ @@ -550,10 +633,85 @@ describe("PhantomClient Spending Limits Integration", () => { }, ])("should return hasSpendingLimit false when $testName", ({ orgData, walletId }) => { it(`${walletId}`, () => { + // No stamper needed - these tests should fail due to missing policy data const result = checkSpendingLimit(orgData, walletId); expect(result.hasSpendingLimit).toBe(false); }); }); + + it("should return false when user authenticator doesn't match stamper public key", () => { + // Use a valid Solana public key for stamper (base58) - this is a real address + const stamperBase58Key = "11111111111111111111111111111112"; // System program (valid base58) + + // But org data has a different user's key (user_7EYmfEfp... from actual data) + const orgDataBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; + + const orgData = { + users: [ + { + username: "spending-limit-user", + authenticators: [{ publicKey: orgDataBase64urlKey }], + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: { + usdCentsLimitPerDay: 1000, + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, + }, + }, + ], + }; + + // Mock stamper with NON-matching base58 public key + (client as any).stamper = { + getKeyInfo: () => ({ publicKey: stamperBase58Key }), + }; + + const result = checkSpendingLimit(orgData, "wallet-123"); + + expect(result.hasSpendingLimit).toBe(false); + + // Cleanup + (client as any).stamper = undefined; + }); + + it("should work without stamper (no user verification)", () => { + const orgData = { + users: [ + { + username: "spending-limit-user", + authenticators: [{ publicKey: "any-key" }], + policy: { + type: "CEL", + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: { + usdCentsLimitPerDay: 1000, + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, + }, + ], + }; + + // No stamper - should still work but skip user verification + (client as any).stamper = undefined; + + const result = checkSpendingLimit(orgData, "wallet-123"); + + expect(result.hasSpendingLimit).toBe(true); + if (result.hasSpendingLimit) { + expect(result.config.usdCentsLimitPerDay).toBe(1000); + } + }); }); describe("conditions for calling augment endpoint", () => { @@ -617,7 +775,38 @@ describe("PhantomClient Spending Limits Integration", () => { describe("error handling", () => { it("should fail when augmentation fails for user with spending limits", async () => { - mockGetOrganization.mockResolvedValue(createOrgDataWithSpendingLimits("wallet-123")); + // Real base58/base64url key pair + const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; + const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; + + const orgDataWithLimits = { + users: [ + { + username: "spending-limit-user", + authenticators: [{ publicKey: mockBase64urlKey }], + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: { + usdCentsLimitPerDay: 1000, + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, + }, + }, + ], + }; + + mockGetOrganization.mockResolvedValue(orgDataWithLimits); + + // Mock stamper to match the user's authenticator (base58 format) + (client as any).stamper = { + getKeyInfo: () => ({ publicKey: mockBase58Key }), + }; mockAxiosPost.mockRejectedValueOnce(new Error("Augmentation service unavailable")); @@ -629,6 +818,9 @@ describe("PhantomClient Spending Limits Integration", () => { true, ), ).rejects.toThrow("Failed to apply spending limits for this transaction"); + + // Cleanup + (client as any).stamper = undefined; }); it("should fail when getOrganization fails for Solana transaction", async () => { @@ -680,6 +872,7 @@ describe("PhantomClient Spending Limits Integration", () => { describe("uses augmented transaction for signing", () => { it("should use augmented transaction returned from augment endpoint", async () => { const spendingConfig = { + usdCentsLimitPerDay: 1000, // $10.00 per day memoryAccount: "MemAcc123", memoryId: 0, memoryBump: 255, @@ -705,7 +898,38 @@ describe("PhantomClient Spending Limits Integration", () => { }); it("should include spending limit config in sign request when present", async () => { - mockGetOrganization.mockResolvedValue(createOrgDataWithSpendingLimits("wallet-123")); + // Use the same real base58/base64url key pair + const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; + const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; + + const orgDataWithLimits = { + users: [ + { + username: "spending-limit-user", + authenticators: [{ publicKey: mockBase64urlKey }], + policy: { + type: "CEL", + cel: { + preset: "DAPP_CONNECTION_USER", + walletId: "wallet-123", + usdLimit: { + usdCentsLimitPerDay: 1000, + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, + }, + }, + ], + }; + + mockGetOrganization.mockResolvedValue(orgDataWithLimits); + + // Mock stamper to match the user's authenticator (base58 format) + (client as any).stamper = { + getKeyInfo: () => ({ publicKey: mockBase58Key }), + }; mockAxiosPost.mockResolvedValueOnce({ data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, @@ -725,6 +949,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect.objectContaining({ params: expect.objectContaining({ spendingLimitConfig: { + usdCentsLimitPerDay: 1000, memoryAccount: "MemAcc123", memoryId: 0, memoryBump: 255, @@ -732,6 +957,9 @@ describe("PhantomClient Spending Limits Integration", () => { }), }), ); + + // Cleanup + (client as any).stamper = undefined; }); it("should NOT include spending limit config when user has no limits", async () => { @@ -759,6 +987,7 @@ describe("PhantomClient Spending Limits Integration", () => { describe("augment endpoint request structure", () => { const spendingConfig = { + usdCentsLimitPerDay: 1000, // $10.00 per day memoryAccount: "MemAcc123", memoryId: 0, memoryBump: 255, @@ -781,7 +1010,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/augment/spending-limit", expect.objectContaining({ - transaction: { Solana: "solana-tx-base64" }, + transaction: { solana: "solana-tx-base64" }, }), expect.any(Object), ); @@ -813,7 +1042,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/augment/spending-limit", { - transaction: { Solana: "tx-base64" }, + transaction: { solana: "tx-base64" }, spendingLimitConfig: spendingConfig, submissionConfig, simulationConfig: { account: "UserAccount123" }, diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 4193e41c..3d042646 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -38,7 +38,9 @@ import { type SignTransactionRequest, } from "@phantom/openapi-wallet-service"; import axios, { type AxiosInstance } from "axios"; +import bs58 from "bs58"; import { Buffer } from "buffer"; +import { base64urlEncode } from "@phantom/base64url"; import { deriveSubmissionConfig } from "./caip2-mappings"; import { DerivationPath, getNetworkConfig } from "./constants"; import { @@ -71,6 +73,41 @@ type AddUserToOrganizationParams = Omit & // Type for spending limit check result type SpendingLimitCheckResult = { hasSpendingLimit: true; config: SpendingLimitConfig } | { hasSpendingLimit: false }; +// Internal types for organization user data +interface UserAuthenticator { + id?: string; + publicKey: string; + authenticatorName?: string; +} + +interface UserPolicy { + type: string; + preset?: string; + walletId?: string; + usdLimit?: { + usdCentsLimitPerDay?: number; + memoryAccount?: string; + memoryId?: number; + memoryBump?: number; + }; + cel?: { + preset?: string; + walletId?: string; + usdLimit?: { + usdCentsLimitPerDay?: number; + memoryAccount?: string; + memoryId?: number; + memoryBump?: number; + }; + }; +} + +interface OrganizationUser { + username: string; + authenticators?: UserAuthenticator[]; + policy?: UserPolicy; +} + // TODO(napas): Auto generate this from the OpenAPI spec export interface SubmissionConfig { chain: string; // e.g., 'solana', 'ethereum', 'polygon' @@ -212,13 +249,21 @@ export class PhantomClient { submissionConfig: SubmissionConfig, account: string, ): Promise { + console.log("TEST: augmentWithSpendingLimit called", { + transactionLength: transaction.length, + spendingLimitConfig, + submissionConfig, + account, + }); + // This should never happen since we have this check above if (submissionConfig.chain !== "solana") { + console.error("TEST: Attempted to augment non-Solana transaction", { chain: submissionConfig.chain }); throw new Error("Spending limits are only supported for Solana transactions"); } try { - const chainTransaction = { Solana: transaction }; + const chainTransaction = { solana: transaction }; const request = { transaction: chainTransaction, @@ -227,54 +272,185 @@ export class PhantomClient { simulationConfig: { account }, }; + console.log("TEST: Sending augment request to backend", { + url: `${this.config.apiBaseUrl}/augment/spending-limit`, + requestBody: JSON.stringify(request).substring(0, 200), + }); + const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/augment/spending-limit`, request, { headers: { "Content-Type": "application/json", }, }); + console.log("TEST: Augment endpoint response received", { + status: response.status, + hasTransaction: !!response.data.transaction, + hasSimulationResult: !!response.data.simulationResult, + transactionLength: response.data.transaction?.length, + }); + return response.data; } catch (error: any) { - console.error("Failed to augment transaction:", error.response?.data || error.message); + console.error("TEST: Augment endpoint error", { + status: error.response?.status, + errorData: error.response?.data, + errorMessage: error.message, + }); throw new Error(`Failed to augment transaction: ${error.response?.data?.message || error.message}`); } } /** * Check if user has spending limits configured + * First identifies the current user by matching their authenticator public key, + * then checks if that user has spending limits enabled for this wallet * @private */ private checkUserSpendingLimit(orgData: ExternalKmsOrganization, walletId: string): SpendingLimitCheckResult { + console.log("TEST: Checking user spending limits", { + walletId, + totalUsers: orgData.users?.length || 0, + hasStamper: !!this.stamper, + }); + if (!orgData.users) { + console.log("TEST: No users in organization data"); return { hasSpendingLimit: false }; } - for (const user of orgData.users) { - if (!("policy" in user)) continue; + // Get current user's public key from the stamper (if available) + // Stamper returns base58 format, but API uses base64url, so we need to convert + let currentUserPublicKeyBase64url: string | null = null; + + console.log("TEST: Stamper details", { + hasStamper: !!this.stamper, + hasGetKeyInfo: this.stamper && "getKeyInfo" in this.stamper, + stamperType: this.stamper ? typeof this.stamper : "undefined", + stamperKeys: this.stamper ? Object.keys(this.stamper) : [], + }); + + if (this.stamper && "getKeyInfo" in this.stamper) { + const stamperWithKeyInfo = this.stamper as { getKeyInfo: () => { publicKey: string } }; + const keyInfo = stamperWithKeyInfo.getKeyInfo(); + + console.log("TEST: Raw keyInfo from stamper", { + keyInfo, + hasPublicKey: !!keyInfo?.publicKey, + publicKeyValue: keyInfo?.publicKey, + }); + + const base58PublicKey = keyInfo?.publicKey; + + if (base58PublicKey) { + try { + // Convert from base58 (stamper format) to base64url (API format) + const publicKeyBytes = bs58.decode(base58PublicKey); + currentUserPublicKeyBase64url = base64urlEncode(publicKeyBytes); + console.log("TEST: Converted stamper public key", { + base58: base58PublicKey, + base64url: currentUserPublicKeyBase64url, + }); + } catch (e) { + console.error("TEST: Failed to convert stamper public key from base58 to base64url", { + base58PublicKey, + error: e, + }); + } + } + } else { + console.log("TEST: No stamper available, will skip user verification"); + } + + // Cast users to our internal type for better type safety + // The OpenAPI types are incomplete for the policy field, so we use our own types + const users = (orgData.users || []) as unknown as OrganizationUser[]; + + // Iterate through users to find the one making this request + for (const user of users) { + // Skip if user has no authenticators + if (!user.authenticators || !Array.isArray(user.authenticators)) { + console.log("TEST: User has no authenticators", { username: user.username }); + continue; + } + + // Check if any of this user's authenticators match the current user's public key (base64url format) + const isCurrentUser = currentUserPublicKeyBase64url + ? user.authenticators.some(auth => auth.publicKey === currentUserPublicKeyBase64url) + : false; + + if (currentUserPublicKeyBase64url && !isCurrentUser) { + console.log("TEST: User authenticator public key doesn't match current user, skipping", { + username: user.username, + }); + continue; + } + + console.log("TEST: Found current user or no stamper verification needed", { + username: user.username, + isCurrentUser, + hasStamper: !!currentUserPublicKeyBase64url, + }); + + // Check if this user has a policy + if (!user.policy) { + console.log("TEST: Current user has no policy", { username: user.username }); + return { hasSpendingLimit: false }; + } + + const policy = user.policy; + console.log("TEST: Checking user policy", { + username: user.username, + policyType: policy.type, + preset: policy.preset, + policyWalletId: policy.walletId, + hasUsdLimit: !!policy.usdLimit, + fullPolicy: JSON.stringify(policy, null, 2), + }); - const policy = (user as any).policy; + // Check if this user has valid spending limits for this wallet + // For preset policies, the structure is flat: policy.preset, policy.walletId, policy.usdLimit + // For full CEL policies, the structure is nested: policy.cel.rules, policy.cel.constants + // We check both formats for backward compatibility + const preset = policy.preset || policy.cel?.preset; + const policyWalletId = policy.walletId || policy.cel?.walletId; + const usdLimit = policy.usdLimit || policy.cel?.usdLimit; - // Check if this user matches the wallet and has valid spending limits if ( - policy?.type === "CEL" && - policy?.cel?.preset === "DAPP_CONNECTION_USER" && - policy?.cel?.walletId === walletId && - policy?.cel?.usdLimit && - typeof policy.cel.usdLimit.memoryAccount === "string" && - typeof policy.cel.usdLimit.memoryId === "number" && - typeof policy.cel.usdLimit.memoryBump === "number" + policy.type === "CEL" && + preset === "DAPP_CONNECTION_USER" && + policyWalletId === walletId && + usdLimit && + typeof usdLimit.usdCentsLimitPerDay === "number" && + typeof usdLimit.memoryAccount === "string" && + typeof usdLimit.memoryId === "number" && + typeof usdLimit.memoryBump === "number" ) { + const config: SpendingLimitConfig = { + usdCentsLimitPerDay: usdLimit.usdCentsLimitPerDay, + memoryAccount: usdLimit.memoryAccount, + memoryId: usdLimit.memoryId, + memoryBump: usdLimit.memoryBump, + }; + console.log("TEST: Found user with valid spending limits", { + username: user.username, + config, + }); return { hasSpendingLimit: true, - config: { - memoryAccount: policy.cel.usdLimit.memoryAccount, - memoryId: policy.cel.usdLimit.memoryId, - memoryBump: policy.cel.usdLimit.memoryBump, - }, + config, }; } + + // Current user found but no spending limits for this wallet + console.log("TEST: Current user found but no spending limits for this wallet", { + username: user.username, + walletId, + }); + return { hasSpendingLimit: false }; } + console.log("TEST: No matching user found in organization"); return { hasSpendingLimit: false }; } @@ -331,13 +507,31 @@ export class PhantomClient { let spendingLimitConfig: SpendingLimitConfig | undefined; let augmentedTransaction = encodedTransaction; + console.log("TEST: Starting spending limits flow", { + isEvmTransaction, + includeSubmissionConfig, + hasSubmissionConfig: !!submissionConfig, + hasAccount: !!params.account, + walletId, + networkId: networkIdParam, + }); + // Only check spending limits for Solana transactions if (!isEvmTransaction && includeSubmissionConfig && submissionConfig && params.account) { + console.log("TEST: Conditions met for spending limit check, fetching organization data"); let orgData: ExternalKmsOrganization; try { orgData = await this.getOrganization(this.config.organizationId); + console.log("TEST: Successfully fetched organization data", { + organizationId: this.config.organizationId, + userCount: orgData.users?.length || 0, + }); } catch (e: any) { + console.error("TEST: Failed to fetch organization data", { + organizationId: this.config.organizationId, + error: e?.message || e, + }); throw new Error( `Failed to fetch organization data for spending limit validation: ${e?.message || e}. ` + `Cannot proceed with transaction without verifying spending limits.`, @@ -345,14 +539,23 @@ export class PhantomClient { } const spendingLimitCheck = this.checkUserSpendingLimit(orgData, walletId); + console.log("TEST: Spending limit check result", { + walletId, + hasSpendingLimit: spendingLimitCheck.hasSpendingLimit, + config: spendingLimitCheck.hasSpendingLimit ? spendingLimitCheck.config : null, + }); if (spendingLimitCheck.hasSpendingLimit) { spendingLimitConfig = spendingLimitCheck.config; - console.log("Found spending limit config for user:", spendingLimitConfig); + console.log("TEST: User has spending limits, calling augment endpoint", { + memoryAccount: spendingLimitConfig.memoryAccount, + memoryId: spendingLimitConfig.memoryId, + memoryBump: spendingLimitConfig.memoryBump, + submissionConfig, + account: params.account, + }); try { - console.log("Augmenting transaction with spending limits..."); - const augmentResponse = await this.augmentWithSpendingLimit( encodedTransaction, spendingLimitConfig, @@ -361,14 +564,32 @@ export class PhantomClient { ); augmentedTransaction = augmentResponse.transaction; - console.log("Transaction augmented with Lighthouse instructions"); + console.log("TEST: Transaction successfully augmented", { + originalTxLength: encodedTransaction.length, + augmentedTxLength: augmentedTransaction.length, + hasSimulationResult: !!augmentResponse.simulationResult, + }); } catch (e: any) { + console.error("TEST: Augmentation failed for user with spending limits", { + error: e?.message || e, + spendingLimitConfig, + }); throw new Error( `Failed to apply spending limits for this transaction: ${e?.message || e}. ` + `Transaction cannot proceed without spending limit enforcement.`, ); } + } else { + console.log("TEST: User has no spending limits, proceeding with original transaction"); } + } else { + console.log("TEST: Skipping spending limits check", { + reason: !isEvmTransaction ? "Solana but missing params" : "EVM transaction", + isEvmTransaction, + includeSubmissionConfig, + hasSubmissionConfig: !!submissionConfig, + hasAccount: !!params.account, + }); } // Phase 2: Sign the (possibly augmented) transaction diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 5e81fc9d..fb603e6b 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -133,6 +133,7 @@ export interface UserConfig { // ============================================================================ export interface SpendingLimitConfig { + usdCentsLimitPerDay: number; memoryAccount: string; memoryId: number; memoryBump: number; From bb774387ee85d734c7f36c096f4477f94294be9f Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Sat, 1 Nov 2025 13:11:30 -0700 Subject: [PATCH 07/33] Removing debug logging --- packages/client/src/PhantomClient.ts | 121 --------------------------- 1 file changed, 121 deletions(-) diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 3d042646..3d20d723 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -249,16 +249,9 @@ export class PhantomClient { submissionConfig: SubmissionConfig, account: string, ): Promise { - console.log("TEST: augmentWithSpendingLimit called", { - transactionLength: transaction.length, - spendingLimitConfig, - submissionConfig, - account, - }); // This should never happen since we have this check above if (submissionConfig.chain !== "solana") { - console.error("TEST: Attempted to augment non-Solana transaction", { chain: submissionConfig.chain }); throw new Error("Spending limits are only supported for Solana transactions"); } @@ -272,10 +265,6 @@ export class PhantomClient { simulationConfig: { account }, }; - console.log("TEST: Sending augment request to backend", { - url: `${this.config.apiBaseUrl}/augment/spending-limit`, - requestBody: JSON.stringify(request).substring(0, 200), - }); const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/augment/spending-limit`, request, { headers: { @@ -283,20 +272,9 @@ export class PhantomClient { }, }); - console.log("TEST: Augment endpoint response received", { - status: response.status, - hasTransaction: !!response.data.transaction, - hasSimulationResult: !!response.data.simulationResult, - transactionLength: response.data.transaction?.length, - }); return response.data; } catch (error: any) { - console.error("TEST: Augment endpoint error", { - status: error.response?.status, - errorData: error.response?.data, - errorMessage: error.message, - }); throw new Error(`Failed to augment transaction: ${error.response?.data?.message || error.message}`); } } @@ -308,14 +286,8 @@ export class PhantomClient { * @private */ private checkUserSpendingLimit(orgData: ExternalKmsOrganization, walletId: string): SpendingLimitCheckResult { - console.log("TEST: Checking user spending limits", { - walletId, - totalUsers: orgData.users?.length || 0, - hasStamper: !!this.stamper, - }); if (!orgData.users) { - console.log("TEST: No users in organization data"); return { hasSpendingLimit: false }; } @@ -323,22 +295,11 @@ export class PhantomClient { // Stamper returns base58 format, but API uses base64url, so we need to convert let currentUserPublicKeyBase64url: string | null = null; - console.log("TEST: Stamper details", { - hasStamper: !!this.stamper, - hasGetKeyInfo: this.stamper && "getKeyInfo" in this.stamper, - stamperType: this.stamper ? typeof this.stamper : "undefined", - stamperKeys: this.stamper ? Object.keys(this.stamper) : [], - }); if (this.stamper && "getKeyInfo" in this.stamper) { const stamperWithKeyInfo = this.stamper as { getKeyInfo: () => { publicKey: string } }; const keyInfo = stamperWithKeyInfo.getKeyInfo(); - console.log("TEST: Raw keyInfo from stamper", { - keyInfo, - hasPublicKey: !!keyInfo?.publicKey, - publicKeyValue: keyInfo?.publicKey, - }); const base58PublicKey = keyInfo?.publicKey; @@ -347,19 +308,10 @@ export class PhantomClient { // Convert from base58 (stamper format) to base64url (API format) const publicKeyBytes = bs58.decode(base58PublicKey); currentUserPublicKeyBase64url = base64urlEncode(publicKeyBytes); - console.log("TEST: Converted stamper public key", { - base58: base58PublicKey, - base64url: currentUserPublicKeyBase64url, - }); } catch (e) { - console.error("TEST: Failed to convert stamper public key from base58 to base64url", { - base58PublicKey, - error: e, - }); } } } else { - console.log("TEST: No stamper available, will skip user verification"); } // Cast users to our internal type for better type safety @@ -370,7 +322,6 @@ export class PhantomClient { for (const user of users) { // Skip if user has no authenticators if (!user.authenticators || !Array.isArray(user.authenticators)) { - console.log("TEST: User has no authenticators", { username: user.username }); continue; } @@ -380,33 +331,16 @@ export class PhantomClient { : false; if (currentUserPublicKeyBase64url && !isCurrentUser) { - console.log("TEST: User authenticator public key doesn't match current user, skipping", { - username: user.username, - }); continue; } - console.log("TEST: Found current user or no stamper verification needed", { - username: user.username, - isCurrentUser, - hasStamper: !!currentUserPublicKeyBase64url, - }); // Check if this user has a policy if (!user.policy) { - console.log("TEST: Current user has no policy", { username: user.username }); return { hasSpendingLimit: false }; } const policy = user.policy; - console.log("TEST: Checking user policy", { - username: user.username, - policyType: policy.type, - preset: policy.preset, - policyWalletId: policy.walletId, - hasUsdLimit: !!policy.usdLimit, - fullPolicy: JSON.stringify(policy, null, 2), - }); // Check if this user has valid spending limits for this wallet // For preset policies, the structure is flat: policy.preset, policy.walletId, policy.usdLimit @@ -432,10 +366,6 @@ export class PhantomClient { memoryId: usdLimit.memoryId, memoryBump: usdLimit.memoryBump, }; - console.log("TEST: Found user with valid spending limits", { - username: user.username, - config, - }); return { hasSpendingLimit: true, config, @@ -443,14 +373,9 @@ export class PhantomClient { } // Current user found but no spending limits for this wallet - console.log("TEST: Current user found but no spending limits for this wallet", { - username: user.username, - walletId, - }); return { hasSpendingLimit: false }; } - console.log("TEST: No matching user found in organization"); return { hasSpendingLimit: false }; } @@ -507,31 +432,14 @@ export class PhantomClient { let spendingLimitConfig: SpendingLimitConfig | undefined; let augmentedTransaction = encodedTransaction; - console.log("TEST: Starting spending limits flow", { - isEvmTransaction, - includeSubmissionConfig, - hasSubmissionConfig: !!submissionConfig, - hasAccount: !!params.account, - walletId, - networkId: networkIdParam, - }); // Only check spending limits for Solana transactions if (!isEvmTransaction && includeSubmissionConfig && submissionConfig && params.account) { - console.log("TEST: Conditions met for spending limit check, fetching organization data"); let orgData: ExternalKmsOrganization; try { orgData = await this.getOrganization(this.config.organizationId); - console.log("TEST: Successfully fetched organization data", { - organizationId: this.config.organizationId, - userCount: orgData.users?.length || 0, - }); } catch (e: any) { - console.error("TEST: Failed to fetch organization data", { - organizationId: this.config.organizationId, - error: e?.message || e, - }); throw new Error( `Failed to fetch organization data for spending limit validation: ${e?.message || e}. ` + `Cannot proceed with transaction without verifying spending limits.`, @@ -539,21 +447,9 @@ export class PhantomClient { } const spendingLimitCheck = this.checkUserSpendingLimit(orgData, walletId); - console.log("TEST: Spending limit check result", { - walletId, - hasSpendingLimit: spendingLimitCheck.hasSpendingLimit, - config: spendingLimitCheck.hasSpendingLimit ? spendingLimitCheck.config : null, - }); if (spendingLimitCheck.hasSpendingLimit) { spendingLimitConfig = spendingLimitCheck.config; - console.log("TEST: User has spending limits, calling augment endpoint", { - memoryAccount: spendingLimitConfig.memoryAccount, - memoryId: spendingLimitConfig.memoryId, - memoryBump: spendingLimitConfig.memoryBump, - submissionConfig, - account: params.account, - }); try { const augmentResponse = await this.augmentWithSpendingLimit( @@ -564,32 +460,15 @@ export class PhantomClient { ); augmentedTransaction = augmentResponse.transaction; - console.log("TEST: Transaction successfully augmented", { - originalTxLength: encodedTransaction.length, - augmentedTxLength: augmentedTransaction.length, - hasSimulationResult: !!augmentResponse.simulationResult, - }); } catch (e: any) { - console.error("TEST: Augmentation failed for user with spending limits", { - error: e?.message || e, - spendingLimitConfig, - }); throw new Error( `Failed to apply spending limits for this transaction: ${e?.message || e}. ` + `Transaction cannot proceed without spending limit enforcement.`, ); } } else { - console.log("TEST: User has no spending limits, proceeding with original transaction"); } } else { - console.log("TEST: Skipping spending limits check", { - reason: !isEvmTransaction ? "Solana but missing params" : "EVM transaction", - isEvmTransaction, - includeSubmissionConfig, - hasSubmissionConfig: !!submissionConfig, - hasAccount: !!params.account, - }); } // Phase 2: Sign the (possibly augmented) transaction From 655cb9a24eb0cb8e23ae3d78e111b29fbdf656ac Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Mon, 3 Nov 2025 11:40:27 -0800 Subject: [PATCH 08/33] Moving getOrg call out of SDK, and using internal getOrg in wallet service instead --- packages/client/src/PhantomClient.test.ts | 489 ++++------------------ packages/client/src/PhantomClient.ts | 197 ++------- 2 files changed, 109 insertions(+), 577 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 6b77241e..f9e047ec 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -396,7 +396,8 @@ describe("PhantomClient Spending Limits Integration", () => { const augmentMethod = client["augmentWithSpendingLimit"].bind(client); const result = await augmentMethod( "original-tx-base64", - spendingConfig, + "org-123", + "wallet-123", solanaSubmissionConfig, "UserAccount123", ); @@ -406,7 +407,8 @@ describe("PhantomClient Spending Limits Integration", () => { "https://api.phantom.app/augment/spending-limit", { transaction: { solana: "original-tx-base64" }, - spendingLimitConfig: spendingConfig, + organizationId: "org-123", + walletId: "wallet-123", submissionConfig: solanaSubmissionConfig, simulationConfig: { account: "UserAccount123" }, }, @@ -425,292 +427,9 @@ describe("PhantomClient Spending Limits Integration", () => { const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - await expect(augmentMethod("bad-tx", spendingConfig, solanaSubmissionConfig, "UserAccount123")).rejects.toThrow( - "Failed to augment transaction", - ); - }); - }); - - describe("checkUserSpendingLimit", () => { - const checkSpendingLimit = (orgData: any, walletId: string) => { - return client["checkUserSpendingLimit"](orgData, walletId); - }; - - it("should return spending limit config when user has limits (nested format)", () => { - // Real base58/base64url key pair from actual user data (user_7EYmfEfp...) - const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; // Base58 from stamper - const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; // Base64url from API - - const orgData = { - users: [ - { - username: "spending-limit-user", - authenticators: [{ publicKey: mockBase64urlKey }], - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: { - usdCentsLimitPerDay: 1000, - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }, - }, - ], - }; - - // Mock stamper with getKeyInfo to return base58 encoded public key (as real stampers do) - // The code will convert this to base64url for comparison - (client as any).stamper = { - getKeyInfo: () => ({ publicKey: mockBase58Key }), - }; - - const result = checkSpendingLimit(orgData, "wallet-123"); - - expect(result.hasSpendingLimit).toBe(true); - if (result.hasSpendingLimit) { - expect(result.config.usdCentsLimitPerDay).toBe(1000); - expect(result.config.memoryAccount).toBe("MemAcc123"); - expect(result.config.memoryId).toBe(0); - expect(result.config.memoryBump).toBe(255); - } - - // Cleanup - (client as any).stamper = undefined; - }); - - it("should return spending limit config when user has limits (flat format - actual API)", () => { - // Use same real base58/base64url key pair as first test - const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; - const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; - - const orgData = { - users: [ - { - username: "spending-limit-user", - authenticators: [{ publicKey: mockBase64urlKey }], - policy: { - type: "CEL", - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: { - usdCentsLimitPerDay: 500, // $5.00 per day - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }, - ], - }; - - // Mock stamper with getKeyInfo to return base58 encoded public key (as real stampers do) - (client as any).stamper = { - getKeyInfo: () => ({ publicKey: mockBase58Key }), - }; - - const result = checkSpendingLimit(orgData, "wallet-123"); - - expect(result.hasSpendingLimit).toBe(true); - if (result.hasSpendingLimit) { - expect(result.config.usdCentsLimitPerDay).toBe(500); - expect(result.config.memoryAccount).toBe("MemAcc123"); - expect(result.config.memoryId).toBe(0); - expect(result.config.memoryBump).toBe(255); - } - - // Cleanup - (client as any).stamper = undefined; - }); - - describe.each([ - { - testName: "no user has spending limits", - orgData: createOrgDataWithoutLimits(), - walletId: "wallet-123", - }, - { - testName: "wallet ID doesn't match", - orgData: createOrgDataWithSpendingLimits("wallet-456"), - walletId: "wallet-123", - }, - { - testName: "policy is not CEL type", - orgData: { - users: [ - { - username: "user", - policy: { - type: "ADMIN", - cel: { walletId: "wallet-123", usdLimit: {} }, - }, - }, - ], - }, - walletId: "wallet-123", - }, - { - testName: "preset is not DAPP_CONNECTION_USER", - orgData: { - users: [ - { - username: "user", - policy: { - type: "CEL", - cel: { - preset: "OTHER_PRESET", - walletId: "wallet-123", - usdLimit: {}, - }, - }, - }, - ], - }, - walletId: "wallet-123", - }, - { - testName: "usdLimit is missing", - orgData: { - users: [ - { - username: "user", - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - }, - }, - }, - ], - }, - walletId: "wallet-123", - }, - { - testName: "usdLimit is null", - orgData: { - users: [ - { - username: "user", - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: null, - }, - }, - }, - ], - }, - walletId: "wallet-123", - }, - { - testName: "usdLimit properties are missing", - orgData: { - users: [ - { - username: "user", - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: { - memoryAccount: "MemAcc123", - // Missing memoryId and memoryBump - }, - }, - }, - }, - ], - }, - walletId: "wallet-123", - }, - ])("should return hasSpendingLimit false when $testName", ({ orgData, walletId }) => { - it(`${walletId}`, () => { - // No stamper needed - these tests should fail due to missing policy data - const result = checkSpendingLimit(orgData, walletId); - expect(result.hasSpendingLimit).toBe(false); - }); - }); - - it("should return false when user authenticator doesn't match stamper public key", () => { - // Use a valid Solana public key for stamper (base58) - this is a real address - const stamperBase58Key = "11111111111111111111111111111112"; // System program (valid base58) - - // But org data has a different user's key (user_7EYmfEfp... from actual data) - const orgDataBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; - - const orgData = { - users: [ - { - username: "spending-limit-user", - authenticators: [{ publicKey: orgDataBase64urlKey }], - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: { - usdCentsLimitPerDay: 1000, - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }, - }, - ], - }; - - // Mock stamper with NON-matching base58 public key - (client as any).stamper = { - getKeyInfo: () => ({ publicKey: stamperBase58Key }), - }; - - const result = checkSpendingLimit(orgData, "wallet-123"); - - expect(result.hasSpendingLimit).toBe(false); - - // Cleanup - (client as any).stamper = undefined; - }); - - it("should work without stamper (no user verification)", () => { - const orgData = { - users: [ - { - username: "spending-limit-user", - authenticators: [{ publicKey: "any-key" }], - policy: { - type: "CEL", - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: { - usdCentsLimitPerDay: 1000, - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }, - ], - }; - - // No stamper - should still work but skip user verification - (client as any).stamper = undefined; - - const result = checkSpendingLimit(orgData, "wallet-123"); - - expect(result.hasSpendingLimit).toBe(true); - if (result.hasSpendingLimit) { - expect(result.config.usdCentsLimitPerDay).toBe(1000); - } + await expect( + augmentMethod("bad-tx", "org-123", "wallet-123", solanaSubmissionConfig, "UserAccount123"), + ).rejects.toThrow("Failed to augment transaction"); }); }); @@ -719,21 +438,34 @@ describe("PhantomClient Spending Limits Integration", () => { return client["performTransactionSigning"](params, includeSubmissionConfig); }; - describe.each([ - { - testName: "user has no spending limits", - orgData: createOrgDataWithoutLimits(), - params: { + it("should call augment but proceed without limits when no spending limits found", async () => { + // Mock augment endpoint to reject with "No spending limit configuration found" + mockAxiosPost.mockRejectedValueOnce({ + message: "Failed to augment transaction: No spending limit configuration found for wallet wallet-123", + }); + + mockKmsPost.mockResolvedValue({ + data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, + }); + + await performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", }, - includeSubmissionConfig: true, - }, + true, + ); + + // Augment should be called but error is caught and we proceed + expect(mockAxiosPost).toHaveBeenCalled(); + expect(mockKmsPost).toHaveBeenCalled(); + }); + + describe.each([ { testName: "includeSubmissionConfig is false", - orgData: createOrgDataWithSpendingLimits("wallet-123"), params: { walletId: "wallet-123", transaction: "tx", @@ -744,13 +476,11 @@ describe("PhantomClient Spending Limits Integration", () => { }, { testName: "account parameter is missing", - orgData: createOrgDataWithSpendingLimits("wallet-123"), params: { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET }, includeSubmissionConfig: true, }, { testName: "transaction is EVM (not Solana)", - orgData: createOrgDataWithSpendingLimits("wallet-123"), params: { walletId: "wallet-123", transaction: "0x1234", @@ -759,9 +489,8 @@ describe("PhantomClient Spending Limits Integration", () => { }, includeSubmissionConfig: true, }, - ])("should NOT call augment when $testName", ({ orgData, params, includeSubmissionConfig }) => { + ])("should NOT call augment when $testName", ({ params, includeSubmissionConfig }) => { it(`${params.networkId}`, async () => { - mockGetOrganization.mockResolvedValue(orgData); mockKmsPost.mockResolvedValue({ data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, }); @@ -774,40 +503,8 @@ describe("PhantomClient Spending Limits Integration", () => { }); describe("error handling", () => { - it("should fail when augmentation fails for user with spending limits", async () => { - // Real base58/base64url key pair - const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; - const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; - - const orgDataWithLimits = { - users: [ - { - username: "spending-limit-user", - authenticators: [{ publicKey: mockBase64urlKey }], - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: { - usdCentsLimitPerDay: 1000, - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }, - }, - ], - }; - - mockGetOrganization.mockResolvedValue(orgDataWithLimits); - - // Mock stamper to match the user's authenticator (base58 format) - (client as any).stamper = { - getKeyInfo: () => ({ publicKey: mockBase58Key }), - }; - + it("should fail when augmentation service fails with non-spending-limit error", async () => { + // Mock augment endpoint to fail with a real error (not "No spending limit configuration found") mockAxiosPost.mockRejectedValueOnce(new Error("Augmentation service unavailable")); const performSigning = client["performTransactionSigning"].bind(client); @@ -818,27 +515,30 @@ describe("PhantomClient Spending Limits Integration", () => { true, ), ).rejects.toThrow("Failed to apply spending limits for this transaction"); - - // Cleanup - (client as any).stamper = undefined; }); - it("should fail when getOrganization fails for Solana transaction", async () => { - mockGetOrganization.mockRejectedValue(new Error("API connection timeout")); + it("should continue signing when augment endpoint returns no spending limits", async () => { + // Mock augment endpoint to return "No spending limit configuration found" + mockAxiosPost.mockRejectedValueOnce({ + message: + "Failed to augment transaction: No spending limit configuration found for wallet wallet-123 in organization org-123", + }); + + mockKmsPost.mockResolvedValueOnce({ + data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, + }); const performSigning = client["performTransactionSigning"].bind(client); + const result = await performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + true, + ); - await expect( - performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, - true, - ), - ).rejects.toThrow("Failed to fetch organization data for spending limit validation"); + // Should proceed with original transaction when no spending limits found + expect(result.signedTransaction).toBe("signed-tx"); }); - it("should continue signing when getOrganization fails for EVM transaction", async () => { - mockGetOrganization.mockRejectedValue(new Error("Failed to fetch organization")); - + it("should not call augment endpoint for EVM transactions", async () => { mockKmsPost.mockResolvedValueOnce({ data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, }); @@ -850,10 +550,10 @@ describe("PhantomClient Spending Limits Integration", () => { ); expect(result.signedTransaction).toBe("signed-tx"); - expect(mockGetOrganization).not.toHaveBeenCalled(); + expect(mockAxiosPost).not.toHaveBeenCalled(); }); - it("should continue signing when includeSubmissionConfig is false (no org fetch needed)", async () => { + it("should not call augment endpoint when includeSubmissionConfig is false", async () => { mockKmsPost.mockResolvedValueOnce({ data: { result: { transaction: "signed-tx" } }, }); @@ -865,19 +565,12 @@ describe("PhantomClient Spending Limits Integration", () => { ); expect(result.signedTransaction).toBe("signed-tx"); - expect(mockGetOrganization).not.toHaveBeenCalled(); + expect(mockAxiosPost).not.toHaveBeenCalled(); }); }); describe("uses augmented transaction for signing", () => { it("should use augmented transaction returned from augment endpoint", async () => { - const spendingConfig = { - usdCentsLimitPerDay: 1000, // $10.00 per day - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }; - const submissionConfig = { chain: "solana" as const, network: "mainnet", @@ -887,52 +580,34 @@ describe("PhantomClient Spending Limits Integration", () => { data: { transaction: "augmented-tx-with-lighthouse-instructions", simulationResult: {}, - memoryConfigUsed: {}, + memoryConfigUsed: { + usdCentsLimitPerDay: 1000, + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, }, }); const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - const result = await augmentMethod("original-tx", spendingConfig, submissionConfig, "UserAccount123"); + const result = await augmentMethod("original-tx", "org-123", "wallet-123", submissionConfig, "UserAccount123"); expect(result.transaction).toBe("augmented-tx-with-lighthouse-instructions"); }); - it("should include spending limit config in sign request when present", async () => { - // Use the same real base58/base64url key pair - const mockBase58Key = "7EYmfEfph6Ki3wNWCBs9HyFUh5sdChnvy3xthjeSiGxT"; - const mockBase64urlKey = "XJ6YMh3KfgHFk1RS6O4beNSJfrIL7kMsjTSQjH7YtEQ"; - - const orgDataWithLimits = { - users: [ - { - username: "spending-limit-user", - authenticators: [{ publicKey: mockBase64urlKey }], - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId: "wallet-123", - usdLimit: { - usdCentsLimitPerDay: 1000, - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }, - }, - ], - }; - - mockGetOrganization.mockResolvedValue(orgDataWithLimits); - - // Mock stamper to match the user's authenticator (base58 format) - (client as any).stamper = { - getKeyInfo: () => ({ publicKey: mockBase58Key }), - }; - + it("should include spending limit config in sign request when augment returns config", async () => { + // Mock augment endpoint to return spending limit config mockAxiosPost.mockResolvedValueOnce({ - data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, + data: { + transaction: "augmented-tx", + simulationResult: {}, + memoryConfigUsed: { + usdCentsLimitPerDay: 1000, + memoryAccount: "MemAcc123", + memoryId: 0, + memoryBump: 255, + }, + }, }); mockKmsPost.mockResolvedValueOnce({ @@ -945,6 +620,7 @@ describe("PhantomClient Spending Limits Integration", () => { true, ); + // Should include the spending limit config returned from augment endpoint expect(mockKmsPost).toHaveBeenCalledWith( expect.objectContaining({ params: expect.objectContaining({ @@ -957,13 +633,13 @@ describe("PhantomClient Spending Limits Integration", () => { }), }), ); - - // Cleanup - (client as any).stamper = undefined; }); - it("should NOT include spending limit config when user has no limits", async () => { - mockGetOrganization.mockResolvedValue(createOrgDataWithoutLimits()); + it("should NOT include spending limit config when augment fails with no limits found", async () => { + // Mock augment endpoint to fail with "No spending limit configuration found" + mockAxiosPost.mockRejectedValueOnce({ + message: "Failed to augment transaction: No spending limit configuration found for wallet wallet-123", + }); mockKmsPost.mockResolvedValueOnce({ data: { result: { transaction: "signed-tx" } }, @@ -975,6 +651,7 @@ describe("PhantomClient Spending Limits Integration", () => { true, ); + // Should not include spending limit config when no limits found expect(mockKmsPost).toHaveBeenCalledWith( expect.objectContaining({ params: expect.not.objectContaining({ @@ -1001,7 +678,8 @@ describe("PhantomClient Spending Limits Integration", () => { const augmentMethod = client["augmentWithSpendingLimit"].bind(client); const result = await augmentMethod( "solana-tx-base64", - spendingConfig, + "org-123", + "wallet-123", { chain: "solana", network: "mainnet" }, "UserAccount123", ); @@ -1011,6 +689,8 @@ describe("PhantomClient Spending Limits Integration", () => { "https://api.phantom.app/augment/spending-limit", expect.objectContaining({ transaction: { solana: "solana-tx-base64" }, + organizationId: "org-123", + walletId: "wallet-123", }), expect.any(Object), ); @@ -1020,7 +700,7 @@ describe("PhantomClient Spending Limits Integration", () => { const augmentMethod = client["augmentWithSpendingLimit"].bind(client); await expect( - augmentMethod("evm-tx", spendingConfig, { chain: "ethereum", network: "mainnet" }, "0xUserAccount"), + augmentMethod("evm-tx", "org-123", "wallet-123", { chain: "ethereum", network: "mainnet" }, "0xUserAccount"), ).rejects.toThrow("Spending limits are only supported for Solana transactions"); expect(mockAxiosPost).not.toHaveBeenCalled(); @@ -1037,13 +717,14 @@ describe("PhantomClient Spending Limits Integration", () => { }; const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - await augmentMethod("tx-base64", spendingConfig, submissionConfig, "UserAccount123"); + await augmentMethod("tx-base64", "org-123", "wallet-123", submissionConfig, "UserAccount123"); expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/augment/spending-limit", { transaction: { solana: "tx-base64" }, - spendingLimitConfig: spendingConfig, + organizationId: "org-123", + walletId: "wallet-123", submissionConfig, simulationConfig: { account: "UserAccount123" }, }, diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 3d20d723..e59848e5 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -38,9 +38,7 @@ import { type SignTransactionRequest, } from "@phantom/openapi-wallet-service"; import axios, { type AxiosInstance } from "axios"; -import bs58 from "bs58"; import { Buffer } from "buffer"; -import { base64urlEncode } from "@phantom/base64url"; import { deriveSubmissionConfig } from "./caip2-mappings"; import { DerivationPath, getNetworkConfig } from "./constants"; import { @@ -70,44 +68,6 @@ type AddUserToOrganizationParams = Omit & user: PartialKmsUser & { traits: { appId: string }; expiresInMs?: number }; }; -// Type for spending limit check result -type SpendingLimitCheckResult = { hasSpendingLimit: true; config: SpendingLimitConfig } | { hasSpendingLimit: false }; - -// Internal types for organization user data -interface UserAuthenticator { - id?: string; - publicKey: string; - authenticatorName?: string; -} - -interface UserPolicy { - type: string; - preset?: string; - walletId?: string; - usdLimit?: { - usdCentsLimitPerDay?: number; - memoryAccount?: string; - memoryId?: number; - memoryBump?: number; - }; - cel?: { - preset?: string; - walletId?: string; - usdLimit?: { - usdCentsLimitPerDay?: number; - memoryAccount?: string; - memoryId?: number; - memoryBump?: number; - }; - }; -} - -interface OrganizationUser { - username: string; - authenticators?: UserAuthenticator[]; - policy?: UserPolicy; -} - // TODO(napas): Auto generate this from the OpenAPI spec export interface SubmissionConfig { chain: string; // e.g., 'solana', 'ethereum', 'polygon' @@ -245,11 +205,11 @@ export class PhantomClient { */ private async augmentWithSpendingLimit( transaction: string, - spendingLimitConfig: SpendingLimitConfig, + organizationId: string, + walletId: string, submissionConfig: SubmissionConfig, account: string, ): Promise { - // This should never happen since we have this check above if (submissionConfig.chain !== "solana") { throw new Error("Spending limits are only supported for Solana transactions"); @@ -260,125 +220,24 @@ export class PhantomClient { const request = { transaction: chainTransaction, - spendingLimitConfig, + organizationId, + walletId, submissionConfig, simulationConfig: { account }, }; - const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/augment/spending-limit`, request, { headers: { "Content-Type": "application/json", }, }); - return response.data; } catch (error: any) { throw new Error(`Failed to augment transaction: ${error.response?.data?.message || error.message}`); } } - /** - * Check if user has spending limits configured - * First identifies the current user by matching their authenticator public key, - * then checks if that user has spending limits enabled for this wallet - * @private - */ - private checkUserSpendingLimit(orgData: ExternalKmsOrganization, walletId: string): SpendingLimitCheckResult { - - if (!orgData.users) { - return { hasSpendingLimit: false }; - } - - // Get current user's public key from the stamper (if available) - // Stamper returns base58 format, but API uses base64url, so we need to convert - let currentUserPublicKeyBase64url: string | null = null; - - - if (this.stamper && "getKeyInfo" in this.stamper) { - const stamperWithKeyInfo = this.stamper as { getKeyInfo: () => { publicKey: string } }; - const keyInfo = stamperWithKeyInfo.getKeyInfo(); - - - const base58PublicKey = keyInfo?.publicKey; - - if (base58PublicKey) { - try { - // Convert from base58 (stamper format) to base64url (API format) - const publicKeyBytes = bs58.decode(base58PublicKey); - currentUserPublicKeyBase64url = base64urlEncode(publicKeyBytes); - } catch (e) { - } - } - } else { - } - - // Cast users to our internal type for better type safety - // The OpenAPI types are incomplete for the policy field, so we use our own types - const users = (orgData.users || []) as unknown as OrganizationUser[]; - - // Iterate through users to find the one making this request - for (const user of users) { - // Skip if user has no authenticators - if (!user.authenticators || !Array.isArray(user.authenticators)) { - continue; - } - - // Check if any of this user's authenticators match the current user's public key (base64url format) - const isCurrentUser = currentUserPublicKeyBase64url - ? user.authenticators.some(auth => auth.publicKey === currentUserPublicKeyBase64url) - : false; - - if (currentUserPublicKeyBase64url && !isCurrentUser) { - continue; - } - - - // Check if this user has a policy - if (!user.policy) { - return { hasSpendingLimit: false }; - } - - const policy = user.policy; - - // Check if this user has valid spending limits for this wallet - // For preset policies, the structure is flat: policy.preset, policy.walletId, policy.usdLimit - // For full CEL policies, the structure is nested: policy.cel.rules, policy.cel.constants - // We check both formats for backward compatibility - const preset = policy.preset || policy.cel?.preset; - const policyWalletId = policy.walletId || policy.cel?.walletId; - const usdLimit = policy.usdLimit || policy.cel?.usdLimit; - - if ( - policy.type === "CEL" && - preset === "DAPP_CONNECTION_USER" && - policyWalletId === walletId && - usdLimit && - typeof usdLimit.usdCentsLimitPerDay === "number" && - typeof usdLimit.memoryAccount === "string" && - typeof usdLimit.memoryId === "number" && - typeof usdLimit.memoryBump === "number" - ) { - const config: SpendingLimitConfig = { - usdCentsLimitPerDay: usdLimit.usdCentsLimitPerDay, - memoryAccount: usdLimit.memoryAccount, - memoryId: usdLimit.memoryId, - memoryBump: usdLimit.memoryBump, - }; - return { - hasSpendingLimit: true, - config, - }; - } - - // Current user found but no spending limits for this wallet - return { hasSpendingLimit: false }; - } - - return { hasSpendingLimit: false }; - } - /** * Private method for shared signing logic */ @@ -428,47 +287,39 @@ export class PhantomClient { }; // TWO-PHASE SPENDING LIMITS FLOW - // Phase 1: Check if user has spending limits and augment transaction if needed + // Phase 1: Call wallet service to check spending limits and augment transaction if needed let spendingLimitConfig: SpendingLimitConfig | undefined; let augmentedTransaction = encodedTransaction; - // Only check spending limits for Solana transactions if (!isEvmTransaction && includeSubmissionConfig && submissionConfig && params.account) { - let orgData: ExternalKmsOrganization; - try { - orgData = await this.getOrganization(this.config.organizationId); - } catch (e: any) { - throw new Error( - `Failed to fetch organization data for spending limit validation: ${e?.message || e}. ` + - `Cannot proceed with transaction without verifying spending limits.`, + // Call wallet service augment endpoint + // It will query KMS, find the first user with spending limits for this wallet, and augment if needed + const augmentResponse = await this.augmentWithSpendingLimit( + encodedTransaction, + this.config.organizationId, + walletId, + submissionConfig, + params.account, ); - } - - const spendingLimitCheck = this.checkUserSpendingLimit(orgData, walletId); - - if (spendingLimitCheck.hasSpendingLimit) { - spendingLimitConfig = spendingLimitCheck.config; - try { - const augmentResponse = await this.augmentWithSpendingLimit( - encodedTransaction, - spendingLimitConfig, - submissionConfig, - params.account, - ); - - augmentedTransaction = augmentResponse.transaction; - } catch (e: any) { + augmentedTransaction = augmentResponse.transaction; + spendingLimitConfig = augmentResponse.memoryConfigUsed; + } catch (e: any) { + // If augmentation fails with "No spending limit configuration found", that means + // no user has spending limits for this wallet, so we can proceed without augmentation + const errorMessage = e?.message || String(e); + if (errorMessage.includes("No spending limit configuration found")) { + // No spending limits configured, proceed with original transaction + } else { + // Real error during augmentation, re-throw throw new Error( - `Failed to apply spending limits for this transaction: ${e?.message || e}. ` + + `Failed to apply spending limits for this transaction: ${errorMessage}. ` + `Transaction cannot proceed without spending limit enforcement.`, ); } - } else { } - } else { } // Phase 2: Sign the (possibly augmented) transaction From bdad9aeacd57f24551ac2d6dc8be45bb3e053561 Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Mon, 3 Nov 2025 16:24:56 -0800 Subject: [PATCH 09/33] No longer expecting failure when user has no spending limit --- packages/client/src/PhantomClient.test.ts | 26 ++++++++++------------- packages/client/src/PhantomClient.ts | 17 ++++----------- packages/client/src/types.ts | 2 +- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index f9e047ec..866847f4 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -438,10 +438,10 @@ describe("PhantomClient Spending Limits Integration", () => { return client["performTransactionSigning"](params, includeSubmissionConfig); }; - it("should call augment but proceed without limits when no spending limits found", async () => { - // Mock augment endpoint to reject with "No spending limit configuration found" - mockAxiosPost.mockRejectedValueOnce({ - message: "Failed to augment transaction: No spending limit configuration found for wallet wallet-123", + it("should call augment and proceed without limits when service returns pass-through", async () => { + // Mock augment endpoint to 200 with same transaction and no memory config + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "tx", simulationResult: {} }, }); mockKmsPost.mockResolvedValue({ @@ -458,7 +458,7 @@ describe("PhantomClient Spending Limits Integration", () => { true, ); - // Augment should be called but error is caught and we proceed + // Augment should be called and we proceed expect(mockAxiosPost).toHaveBeenCalled(); expect(mockKmsPost).toHaveBeenCalled(); }); @@ -517,11 +517,9 @@ describe("PhantomClient Spending Limits Integration", () => { ).rejects.toThrow("Failed to apply spending limits for this transaction"); }); - it("should continue signing when augment endpoint returns no spending limits", async () => { - // Mock augment endpoint to return "No spending limit configuration found" - mockAxiosPost.mockRejectedValueOnce({ - message: - "Failed to augment transaction: No spending limit configuration found for wallet wallet-123 in organization org-123", + it("should continue signing when augment endpoint returns pass-through with no limits", async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "tx", simulationResult: {} }, }); mockKmsPost.mockResolvedValueOnce({ @@ -534,7 +532,6 @@ describe("PhantomClient Spending Limits Integration", () => { true, ); - // Should proceed with original transaction when no spending limits found expect(result.signedTransaction).toBe("signed-tx"); }); @@ -635,10 +632,9 @@ describe("PhantomClient Spending Limits Integration", () => { ); }); - it("should NOT include spending limit config when augment fails with no limits found", async () => { - // Mock augment endpoint to fail with "No spending limit configuration found" - mockAxiosPost.mockRejectedValueOnce({ - message: "Failed to augment transaction: No spending limit configuration found for wallet wallet-123", + it("should NOT include spending limit config when service returns pass-through (no limits)", async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "tx", simulationResult: {} }, }); mockKmsPost.mockResolvedValueOnce({ diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index e59848e5..5b3f9924 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -210,7 +210,6 @@ export class PhantomClient { submissionConfig: SubmissionConfig, account: string, ): Promise { - // This should never happen since we have this check above if (submissionConfig.chain !== "solana") { throw new Error("Spending limits are only supported for Solana transactions"); } @@ -295,7 +294,6 @@ export class PhantomClient { if (!isEvmTransaction && includeSubmissionConfig && submissionConfig && params.account) { try { // Call wallet service augment endpoint - // It will query KMS, find the first user with spending limits for this wallet, and augment if needed const augmentResponse = await this.augmentWithSpendingLimit( encodedTransaction, this.config.organizationId, @@ -307,18 +305,11 @@ export class PhantomClient { augmentedTransaction = augmentResponse.transaction; spendingLimitConfig = augmentResponse.memoryConfigUsed; } catch (e: any) { - // If augmentation fails with "No spending limit configuration found", that means - // no user has spending limits for this wallet, so we can proceed without augmentation const errorMessage = e?.message || String(e); - if (errorMessage.includes("No spending limit configuration found")) { - // No spending limits configured, proceed with original transaction - } else { - // Real error during augmentation, re-throw - throw new Error( - `Failed to apply spending limits for this transaction: ${errorMessage}. ` + - `Transaction cannot proceed without spending limit enforcement.`, - ); - } + throw new Error( + `Failed to apply spending limits for this transaction: ${errorMessage}. ` + + `Transaction cannot proceed without spending limit enforcement.`, + ); } } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index fb603e6b..8417056d 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -142,5 +142,5 @@ export interface SpendingLimitConfig { export interface AugmentWithSpendingLimitResponse { transaction: string; // base64url encoded with Lighthouse instructions simulationResult?: any; - memoryConfigUsed: SpendingLimitConfig; + memoryConfigUsed?: SpendingLimitConfig; } From b11a2612d34e7a7d36685d4d9679bec143705383 Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Mon, 3 Nov 2025 16:35:22 -0800 Subject: [PATCH 10/33] Fixing lints --- packages/client/src/PhantomClient.test.ts | 41 ----------------------- 1 file changed, 41 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 866847f4..a7b5f90c 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -302,40 +302,6 @@ describe("PhantomClient Spending Limits Integration", () => { let mockKmsPost: jest.Mock; let mockGetOrganization: jest.Mock; - // Helper to create org data with spending limits - const createOrgDataWithSpendingLimits = (walletId: string) => ({ - users: [ - { - username: "spending-limit-user", - authenticators: [{ publicKey: "default-test-public-key" }], - policy: { - type: "CEL", - cel: { - preset: "DAPP_CONNECTION_USER", - walletId, - usdLimit: { - usdCentsLimitPerDay: 1000, // $10.00 per day - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }, - }, - ], - }); - - // Helper for org data without spending limits - const createOrgDataWithoutLimits = () => ({ - users: [ - { - username: "admin-user", - authenticators: [{ publicKey: "admin-public-key" }], - policy: { type: "ADMIN" }, - }, - ], - }); - beforeEach(() => { mockAxiosPost = jest.fn(); const mockAxiosInstance = { @@ -659,13 +625,6 @@ describe("PhantomClient Spending Limits Integration", () => { }); describe("augment endpoint request structure", () => { - const spendingConfig = { - usdCentsLimitPerDay: 1000, // $10.00 per day - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }; - it("should send Solana transactions in ChainTransaction format", async () => { mockAxiosPost.mockResolvedValueOnce({ data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, From b2e912b87a3520e667e9e042b6385652f34ec95d Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 13:10:26 +0100 Subject: [PATCH 11/33] Do not send submission config or check --- packages/client/src/PhantomClient.ts | 15 +++++---------- packages/utils/src/index.ts | 2 +- packages/utils/src/network.test.ts | 20 +++++++++++++++++++- packages/utils/src/network.ts | 5 +++++ 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 5b3f9924..d6a2482f 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -61,7 +61,7 @@ import { } from "./types"; import type { Stamper } from "@phantom/sdk-types"; -import { getSecureTimestamp, randomUUID, isEthereumChain } from "@phantom/utils"; +import { getSecureTimestamp, randomUUID, isEthereumChain, isSolanaChain } from "@phantom/utils"; type AddUserToOrganizationParams = Omit & { replaceExpirable?: boolean; @@ -207,13 +207,9 @@ export class PhantomClient { transaction: string, organizationId: string, walletId: string, - submissionConfig: SubmissionConfig, account: string, ): Promise { - if (submissionConfig.chain !== "solana") { - throw new Error("Spending limits are only supported for Solana transactions"); - } - + try { const chainTransaction = { solana: transaction }; @@ -221,7 +217,6 @@ export class PhantomClient { transaction: chainTransaction, organizationId, walletId, - submissionConfig, simulationConfig: { account }, }; @@ -258,6 +253,7 @@ export class PhantomClient { // Check if this is an EVM transaction using the network ID const isEvmTransaction = isEthereumChain(networkIdParam); + const isSolanaTransaction = isSolanaChain(networkIdParam); let submissionConfig: SubmissionConfig | null = null; @@ -291,15 +287,14 @@ export class PhantomClient { let augmentedTransaction = encodedTransaction; // Only check spending limits for Solana transactions - if (!isEvmTransaction && includeSubmissionConfig && submissionConfig && params.account) { + if (isSolanaTransaction) { try { // Call wallet service augment endpoint const augmentResponse = await this.augmentWithSpendingLimit( encodedTransaction, this.config.organizationId, walletId, - submissionConfig, - params.account, + params.account || "", ); augmentedTransaction = augmentResponse.transaction; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6d118435..eaf21d16 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,3 @@ export { randomUUID, randomString } from './uuid'; export { getSecureTimestamp, getSecureTimestampSync } from './time'; -export { isEthereumChain, getChainPrefix } from './network'; \ No newline at end of file +export { isEthereumChain, getChainPrefix, isSolanaChain } from './network'; \ No newline at end of file diff --git a/packages/utils/src/network.test.ts b/packages/utils/src/network.test.ts index 90c0a65b..473c501f 100644 --- a/packages/utils/src/network.test.ts +++ b/packages/utils/src/network.test.ts @@ -1,4 +1,4 @@ -import { isEthereumChain, getChainPrefix } from './network'; +import { isEthereumChain, getChainPrefix, isSolanaChain } from './network'; describe('Network Utilities', () => { describe('isEthereumChain', () => { @@ -66,4 +66,22 @@ describe('Network Utilities', () => { expect(getChainPrefix('eip155')).toBe('eip155'); }); }); + + describe('isSolanaChain', () => { + it('should return true for Solana mainnet', () => { + expect(isSolanaChain('solana:mainnet')).toBe(true); + }); + + it('should return true for Solana devnet', () => { + expect(isSolanaChain('solana:devnet')).toBe(true); + }); + + it('should return false for Ethereum mainnet', () => { + expect(isSolanaChain('eip155:1')).toBe(false); + }); + + it('should return false for Bitcoin', () => { + expect(isSolanaChain('bitcoin:mainnet')).toBe(false); + }); + }); }); diff --git a/packages/utils/src/network.ts b/packages/utils/src/network.ts index a382686e..826bc6a9 100644 --- a/packages/utils/src/network.ts +++ b/packages/utils/src/network.ts @@ -10,3 +10,8 @@ export function isEthereumChain(networkId: string): boolean { export function getChainPrefix(networkId: string): string { return networkId.split(":")[0].toLowerCase(); } + +export function isSolanaChain(networkId: string): boolean { + const network = networkId.split(":")[0].toLowerCase(); + return network === "solana"; +} \ No newline at end of file From 668ee85f6b723519e1106a5673769538a39a3ffc Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 14:28:11 +0100 Subject: [PATCH 12/33] fix tests --- packages/client/src/PhantomClient.test.ts | 22 ++++++---------------- packages/client/src/PhantomClient.ts | 8 +++++--- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index a7b5f90c..464033b6 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -364,7 +364,6 @@ describe("PhantomClient Spending Limits Integration", () => { "original-tx-base64", "org-123", "wallet-123", - solanaSubmissionConfig, "UserAccount123", ); @@ -375,7 +374,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: { solana: "original-tx-base64" }, organizationId: "org-123", walletId: "wallet-123", - submissionConfig: solanaSubmissionConfig, simulationConfig: { account: "UserAccount123" }, }, { headers: { "Content-Type": "application/json" } }, @@ -394,7 +392,7 @@ describe("PhantomClient Spending Limits Integration", () => { const augmentMethod = client["augmentWithSpendingLimit"].bind(client); await expect( - augmentMethod("bad-tx", "org-123", "wallet-123", solanaSubmissionConfig, "UserAccount123"), + augmentMethod("bad-tx", "org-123", "wallet-123", "UserAccount123"), ).rejects.toThrow("Failed to augment transaction"); }); }); @@ -553,7 +551,7 @@ describe("PhantomClient Spending Limits Integration", () => { }); const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - const result = await augmentMethod("original-tx", "org-123", "wallet-123", submissionConfig, "UserAccount123"); + const result = await augmentMethod("original-tx", "org-123", "wallet-123", "UserAccount123"); expect(result.transaction).toBe("augmented-tx-with-lighthouse-instructions"); }); @@ -635,7 +633,6 @@ describe("PhantomClient Spending Limits Integration", () => { "solana-tx-base64", "org-123", "wallet-123", - { chain: "solana", network: "mainnet" }, "UserAccount123", ); @@ -651,15 +648,9 @@ describe("PhantomClient Spending Limits Integration", () => { ); }); - it("should reject EVM transactions with clear error", async () => { - const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - - await expect( - augmentMethod("evm-tx", "org-123", "wallet-123", { chain: "ethereum", network: "mainnet" }, "0xUserAccount"), - ).rejects.toThrow("Spending limits are only supported for Solana transactions"); - - expect(mockAxiosPost).not.toHaveBeenCalled(); - }); + // Note: The augmentWithSpendingLimit method no longer receives chain information, + // so it cannot reject EVM transactions at the method level. Chain validation + // should happen at a higher level before calling this method. it("should include all required fields in augment request", async () => { mockAxiosPost.mockResolvedValueOnce({ @@ -672,7 +663,7 @@ describe("PhantomClient Spending Limits Integration", () => { }; const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - await augmentMethod("tx-base64", "org-123", "wallet-123", submissionConfig, "UserAccount123"); + await augmentMethod("tx-base64", "org-123", "wallet-123", "UserAccount123"); expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/augment/spending-limit", @@ -680,7 +671,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: { solana: "tx-base64" }, organizationId: "org-123", walletId: "wallet-123", - submissionConfig, simulationConfig: { account: "UserAccount123" }, }, { headers: { "Content-Type": "application/json" } }, diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index d6a2482f..56874e54 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -286,15 +286,17 @@ export class PhantomClient { let spendingLimitConfig: SpendingLimitConfig | undefined; let augmentedTransaction = encodedTransaction; - // Only check spending limits for Solana transactions - if (isSolanaTransaction) { + // Only check spending limits for Solana transactions when: + // 1. includeSubmissionConfig is true (i.e., signAndSendTransaction) + // 2. account parameter is provided (needed for simulation) + if (isSolanaTransaction && includeSubmissionConfig && params.account) { try { // Call wallet service augment endpoint const augmentResponse = await this.augmentWithSpendingLimit( encodedTransaction, this.config.organizationId, walletId, - params.account || "", + params.account, ); augmentedTransaction = augmentResponse.transaction; From 661d7c0d9354a6858e27311370eb9ca461ba8a90 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 14:36:38 +0100 Subject: [PATCH 13/33] Restore submission config --- packages/client/src/PhantomClient.test.ts | 2 ++ packages/client/src/PhantomClient.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 464033b6..04e5398b 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -364,6 +364,7 @@ describe("PhantomClient Spending Limits Integration", () => { "original-tx-base64", "org-123", "wallet-123", + solanaSubmissionConfig, "UserAccount123", ); @@ -374,6 +375,7 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: { solana: "original-tx-base64" }, organizationId: "org-123", walletId: "wallet-123", + submissionConfig: solanaSubmissionConfig, simulationConfig: { account: "UserAccount123" }, }, { headers: { "Content-Type": "application/json" } }, diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 56874e54..d83293b4 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -207,9 +207,10 @@ export class PhantomClient { transaction: string, organizationId: string, walletId: string, + submissionConfig: SubmissionConfig, account: string, ): Promise { - + try { const chainTransaction = { solana: transaction }; @@ -217,6 +218,7 @@ export class PhantomClient { transaction: chainTransaction, organizationId, walletId, + submissionConfig, simulationConfig: { account }, }; @@ -289,13 +291,15 @@ export class PhantomClient { // Only check spending limits for Solana transactions when: // 1. includeSubmissionConfig is true (i.e., signAndSendTransaction) // 2. account parameter is provided (needed for simulation) - if (isSolanaTransaction && includeSubmissionConfig && params.account) { + // 3. submissionConfig is available + if (isSolanaTransaction && includeSubmissionConfig && params.account && submissionConfig) { try { // Call wallet service augment endpoint const augmentResponse = await this.augmentWithSpendingLimit( encodedTransaction, this.config.organizationId, walletId, + submissionConfig, params.account, ); From eae38456187e642377dea71e2ea532d236a468da Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 14:53:50 +0100 Subject: [PATCH 14/33] fix test --- packages/client/src/PhantomClient.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 04e5398b..b6654b40 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -394,7 +394,7 @@ describe("PhantomClient Spending Limits Integration", () => { const augmentMethod = client["augmentWithSpendingLimit"].bind(client); await expect( - augmentMethod("bad-tx", "org-123", "wallet-123", "UserAccount123"), + augmentMethod("bad-tx", "org-123", "wallet-123", solanaSubmissionConfig, "UserAccount123"), ).rejects.toThrow("Failed to augment transaction"); }); }); @@ -553,7 +553,7 @@ describe("PhantomClient Spending Limits Integration", () => { }); const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - const result = await augmentMethod("original-tx", "org-123", "wallet-123", "UserAccount123"); + const result = await augmentMethod("original-tx", "org-123", "wallet-123", submissionConfig, "UserAccount123"); expect(result.transaction).toBe("augmented-tx-with-lighthouse-instructions"); }); @@ -626,6 +626,11 @@ describe("PhantomClient Spending Limits Integration", () => { describe("augment endpoint request structure", () => { it("should send Solana transactions in ChainTransaction format", async () => { + const submissionConfig = { + chain: "solana" as const, + network: "mainnet", + }; + mockAxiosPost.mockResolvedValueOnce({ data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, }); @@ -635,6 +640,7 @@ describe("PhantomClient Spending Limits Integration", () => { "solana-tx-base64", "org-123", "wallet-123", + submissionConfig, "UserAccount123", ); @@ -645,6 +651,7 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: { solana: "solana-tx-base64" }, organizationId: "org-123", walletId: "wallet-123", + submissionConfig: submissionConfig, }), expect.any(Object), ); @@ -665,7 +672,7 @@ describe("PhantomClient Spending Limits Integration", () => { }; const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - await augmentMethod("tx-base64", "org-123", "wallet-123", "UserAccount123"); + await augmentMethod("tx-base64", "org-123", "wallet-123", submissionConfig, "UserAccount123"); expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/augment/spending-limit", @@ -673,6 +680,7 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: { solana: "tx-base64" }, organizationId: "org-123", walletId: "wallet-123", + submissionConfig: submissionConfig, simulationConfig: { account: "UserAccount123" }, }, { headers: { "Content-Type": "application/json" } }, From 13b656f9c9bcf780b94d38e16c1b6748a64a7f4c Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 16:06:17 +0100 Subject: [PATCH 15/33] check server-sdk and client-sdk --- packages/client/src/PhantomClient.test.ts | 103 +++++++++++++----- packages/client/src/PhantomClient.ts | 47 ++++---- packages/client/src/types.ts | 2 + .../src/embedded-provider.ts | 18 ++- packages/server-sdk/src/index.ts | 2 + 5 files changed, 117 insertions(+), 55 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index b6654b40..8b07b3f2 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -420,6 +420,7 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", + walletType: "user-wallet", }, true, ); @@ -429,42 +430,80 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockKmsPost).toHaveBeenCalled(); }); - describe.each([ - { - testName: "includeSubmissionConfig is false", - params: { + it("should call augment even when includeSubmissionConfig is false for Solana", async () => { + // Mock augment endpoint to return pass-through + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "tx", simulationResult: {} }, + }); + + mockKmsPost.mockResolvedValue({ + data: { result: { transaction: "signed-tx" } }, + }); + + await performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", + walletType: "user-wallet", }, - includeSubmissionConfig: false, - }, - { - testName: "account parameter is missing", - params: { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET }, - includeSubmissionConfig: true, - }, - { - testName: "transaction is EVM (not Solana)", - params: { + false, // includeSubmissionConfig = false, but augment should still be called + ); + + // Augment should be called even when includeSubmissionConfig is false + expect(mockAxiosPost).toHaveBeenCalled(); + expect(mockKmsPost).toHaveBeenCalled(); + }); + + it("should throw error when account parameter is missing for Solana user-wallet", async () => { + await expect( + performSigning( + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, walletType: "user-wallet" }, + true, + ), + ).rejects.toThrow("Account is required to simulate Solana transactions with spending limits"); + + // Augment should not be called because we fail before reaching it + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); + + it("should NOT call augment for EVM transactions", async () => { + mockKmsPost.mockResolvedValue({ + data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, + }); + + await performSigning( + { walletId: "wallet-123", transaction: "0x1234", networkId: NetworkId.ETHEREUM_MAINNET, account: "0xUser", + walletType: "user-wallet", }, - includeSubmissionConfig: true, - }, - ])("should NOT call augment when $testName", ({ params, includeSubmissionConfig }) => { - it(`${params.networkId}`, async () => { - mockKmsPost.mockResolvedValue({ - data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, - }); + true, + ); - await performSigning(params, includeSubmissionConfig); + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); - expect(mockAxiosPost).not.toHaveBeenCalled(); + it("should NOT call augment for Solana server-wallet transactions", async () => { + mockKmsPost.mockResolvedValue({ + data: { result: { transaction: "signed-tx" }, rpc_submission_result: { result: "hash" } }, }); + + await performSigning( + { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + walletType: "server-wallet", + }, + true, + ); + + expect(mockAxiosPost).not.toHaveBeenCalled(); }); }); @@ -477,7 +516,7 @@ describe("PhantomClient Spending Limits Integration", () => { await expect( performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, true, ), ).rejects.toThrow("Failed to apply spending limits for this transaction"); @@ -494,7 +533,7 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); const result = await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, true, ); @@ -508,7 +547,7 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); const result = await performSigning( - { walletId: "wallet-123", transaction: "0x1234", networkId: NetworkId.ETHEREUM_MAINNET, account: "0xUser" }, + { walletId: "wallet-123", transaction: "0x1234", networkId: NetworkId.ETHEREUM_MAINNET, account: "0xUser", walletType: "user-wallet" }, true, ); @@ -516,19 +555,25 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockAxiosPost).not.toHaveBeenCalled(); }); - it("should not call augment endpoint when includeSubmissionConfig is false", async () => { + it("should call augment endpoint even when includeSubmissionConfig is false", async () => { + // Mock augment endpoint to return pass-through + mockAxiosPost.mockResolvedValueOnce({ + data: { transaction: "tx", simulationResult: {} }, + }); + mockKmsPost.mockResolvedValueOnce({ data: { result: { transaction: "signed-tx" } }, }); const performSigning = client["performTransactionSigning"].bind(client); const result = await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET }, + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, false, ); expect(result.signedTransaction).toBe("signed-tx"); - expect(mockAxiosPost).not.toHaveBeenCalled(); + // Augment should be called even when includeSubmissionConfig is false + expect(mockAxiosPost).toHaveBeenCalled(); }); }); diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index d83293b4..1f209856 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -250,33 +250,28 @@ export class PhantomClient { if (!this.config.organizationId) { throw new Error("organizationId is required to sign a transaction"); } - // Transaction is always a string (encoded via parsers) - const encodedTransaction = transactionParam; - - // Check if this is an EVM transaction using the network ID - const isEvmTransaction = isEthereumChain(networkIdParam); - const isSolanaTransaction = isSolanaChain(networkIdParam); - - let submissionConfig: SubmissionConfig | null = null; - if (includeSubmissionConfig) { - submissionConfig = deriveSubmissionConfig(networkIdParam) || null; + // SubmissionConfig is used to: 1) submit the transaction onchain, 2) derive spending limits + const submissionConfig: SubmissionConfig | null = deriveSubmissionConfig(networkIdParam) || null; - // If we don't have a submission config, the transaction will only be signed, not submitted - if (!submissionConfig) { - console.error( - `No submission config available for network ${networkIdParam}. Transaction will be signed but not submitted.`, - ); - } + if (!submissionConfig) { + throw new Error(`SubmissionConfig could not be derived for network ID: ${networkIdParam}`); } - // Get network configuration with custom derivation index + // Get network configuration with custom derivation index. Required for signing const networkConfig = getNetworkConfig(networkIdParam, derivationIndex); if (!networkConfig) { throw new Error(`Unsupported network ID: ${networkIdParam}`); } + // Transaction is always a string (encoded via parsers) + const encodedTransaction = transactionParam; + + // Check if this is an EVM transaction using the network ID + const isEvmTransaction = isEthereumChain(networkIdParam); + const isSolanaTransaction = isSolanaChain(networkIdParam); + const derivationInfo: DerivationInfo = { derivationPath: networkConfig.derivationPath, curve: networkConfig.curve, @@ -288,19 +283,23 @@ export class PhantomClient { let spendingLimitConfig: SpendingLimitConfig | undefined; let augmentedTransaction = encodedTransaction; - // Only check spending limits for Solana transactions when: - // 1. includeSubmissionConfig is true (i.e., signAndSendTransaction) - // 2. account parameter is provided (needed for simulation) - // 3. submissionConfig is available - if (isSolanaTransaction && includeSubmissionConfig && params.account && submissionConfig) { + // Always check spending limits for Solana transactions + // If we don't receive an account + // At this point, we've already validated that submissionConfig and account exist for Solana + if (isSolanaTransaction && params.walletType === "user-wallet") { + + if (!params.account) { + throw new Error("Account is required to simulate Solana transactions with spending limits"); + } + try { // Call wallet service augment endpoint const augmentResponse = await this.augmentWithSpendingLimit( encodedTransaction, this.config.organizationId, walletId, - submissionConfig, - params.account, + submissionConfig, // Non-null assertion safe because we validated above + params.account, // Non-null assertion safe because we validated above ); augmentedTransaction = augmentResponse.transaction; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 8417056d..538dbbc7 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -66,6 +66,7 @@ export interface SignTransactionParams { networkId: NetworkId; derivationIndex?: number; // Optional account derivation index (defaults to 0) account?: string; // Optional specific account address to use + walletType: "server-wallet" | "user-wallet"; } export interface SignAndSendTransactionParams { @@ -74,6 +75,7 @@ export interface SignAndSendTransactionParams { networkId: NetworkId; derivationIndex?: number; // Optional account derivation index (defaults to 0) account?: string; // Optional specific account address to use + walletType: "server-wallet" | "user-wallet"; } export interface GetWalletWithTagParams { diff --git a/packages/embedded-provider-core/src/embedded-provider.ts b/packages/embedded-provider-core/src/embedded-provider.ts index 94b8434c..9733e0af 100644 --- a/packages/embedded-provider-core/src/embedded-provider.ts +++ b/packages/embedded-provider-core/src/embedded-provider.ts @@ -875,6 +875,12 @@ export class EmbeddedProvider { throw new Error("Failed to parse transaction: no valid encoding found"); } + // Get account, fail if not supported + const account = this.getAddressForNetwork(params.networkId); + if (!account) { + throw new Error(`No address found for network ${params.networkId}`); + } + // Get raw response from client // PhantomClient will handle EVM transaction formatting internally const rawResponse = await this.client.signTransaction({ @@ -882,7 +888,8 @@ export class EmbeddedProvider { transaction: transactionPayload, networkId: params.networkId, derivationIndex: derivationIndex, - account: this.getAddressForNetwork(params.networkId), + account, + walletType: "user-wallet" }); this.logger.info("EMBEDDED_PROVIDER", "Transaction signed successfully", { @@ -926,6 +933,12 @@ export class EmbeddedProvider { throw new Error("Failed to parse transaction: no valid encoding found"); } + // Get account, fail if not supported + const account = this.getAddressForNetwork(params.networkId); + if (!account) { + throw new Error(`No address found for network ${params.networkId}`); + } + // Get raw response from client // PhantomClient will handle EVM transaction formatting internally const rawResponse = await this.client.signAndSendTransaction({ @@ -933,7 +946,8 @@ export class EmbeddedProvider { transaction: transactionPayload, networkId: params.networkId, derivationIndex: derivationIndex, - account: this.getAddressForNetwork(params.networkId), + account, + walletType: "user-wallet" }); this.logger.info("EMBEDDED_PROVIDER", "Transaction signed and sent successfully", { diff --git a/packages/server-sdk/src/index.ts b/packages/server-sdk/src/index.ts index bd2e2640..bbdc6c79 100644 --- a/packages/server-sdk/src/index.ts +++ b/packages/server-sdk/src/index.ts @@ -159,6 +159,7 @@ export class ServerSDK { networkId: params.networkId, derivationIndex: params.derivationIndex, account: params.account, + walletType: "server-wallet", }); // Parse the response to get transaction result (without hash) @@ -188,6 +189,7 @@ export class ServerSDK { networkId: params.networkId, derivationIndex: params.derivationIndex, account: params.account, + walletType: "server-wallet", }); // Parse the response to get transaction hash and explorer URL From e8641476e1c85e10ab06af2c39dc70392b7e5e7a Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 16:07:22 +0100 Subject: [PATCH 16/33] check server-sdk and client-sdk --- packages/client/src/PhantomClient.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 8b07b3f2..4f79a8ba 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -624,7 +624,7 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, true, ); @@ -654,7 +654,7 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123" }, + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, true, ); From 1a5cb6e05962186cf41d70cc2d88414b701d3854 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 16:08:46 +0100 Subject: [PATCH 17/33] check server-sdk and client-sdk --- packages/client/src/PhantomClient.ts | 2 +- packages/embedded-provider-core/src/auth-flow.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 1f209856..b484d968 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -329,7 +329,7 @@ export class PhantomClient { } as any; // Add submission config if available and requested - if (includeSubmissionConfig && submissionConfig) { + if (includeSubmissionConfig) { signRequest.submissionConfig = submissionConfig; } diff --git a/packages/embedded-provider-core/src/auth-flow.test.ts b/packages/embedded-provider-core/src/auth-flow.test.ts index a72fad13..bde5e04d 100644 --- a/packages/embedded-provider-core/src/auth-flow.test.ts +++ b/packages/embedded-provider-core/src/auth-flow.test.ts @@ -1217,7 +1217,13 @@ describe("EmbeddedProvider Auth Flows", () => { return Promise.resolve(); }); - mockClient.getWalletAddresses.mockResolvedValue([]); + mockClient.getWalletAddresses.mockResolvedValue([ + { + addressType: "solana", + address: "test-solana-address", + publicKey: "test-public-key", + }, + ]); await provider.connect({ provider: "google" }); }); From 4eb885c5c253617756d9eb61af03495080181b31 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Tue, 4 Nov 2025 16:09:14 +0100 Subject: [PATCH 18/33] check server-sdk and client-sdk --- packages/embedded-provider-core/src/auth-flow.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/embedded-provider-core/src/auth-flow.test.ts b/packages/embedded-provider-core/src/auth-flow.test.ts index bde5e04d..45018e2c 100644 --- a/packages/embedded-provider-core/src/auth-flow.test.ts +++ b/packages/embedded-provider-core/src/auth-flow.test.ts @@ -1272,6 +1272,8 @@ describe("EmbeddedProvider Auth Flows", () => { transaction: expect.any(String), networkId: NetworkId.SOLANA_MAINNET, derivationIndex: 0, + account: "test-solana-address", + walletType: "user-wallet", }); expect(result.hash).toBeDefined(); expect(typeof result.blockExplorer === "string" || result.blockExplorer === undefined).toBe(true); From 3007aca2eb04c76c1e7a151ecda6efbc6ec2163c Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Tue, 4 Nov 2025 21:13:53 -0800 Subject: [PATCH 19/33] Changing endpoint name --- packages/client/src/PhantomClient.test.ts | 6 +++--- packages/client/src/PhantomClient.ts | 10 +++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 4f79a8ba..1e0b201f 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -370,7 +370,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect(result.transaction).toBe("augmented-tx"); expect(mockAxiosPost).toHaveBeenCalledWith( - "https://api.phantom.app/augment/spending-limit", + "https://api.phantom.app/prepare/spending-limit", { transaction: { solana: "original-tx-base64" }, organizationId: "org-123", @@ -691,7 +691,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect(result.transaction).toBe("augmented-tx"); expect(mockAxiosPost).toHaveBeenCalledWith( - "https://api.phantom.app/augment/spending-limit", + "https://api.phantom.app/prepare/spending-limit", expect.objectContaining({ transaction: { solana: "solana-tx-base64" }, organizationId: "org-123", @@ -720,7 +720,7 @@ describe("PhantomClient Spending Limits Integration", () => { await augmentMethod("tx-base64", "org-123", "wallet-123", submissionConfig, "UserAccount123"); expect(mockAxiosPost).toHaveBeenCalledWith( - "https://api.phantom.app/augment/spending-limit", + "https://api.phantom.app/prepare/spending-limit", { transaction: { solana: "tx-base64" }, organizationId: "org-123", diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index b484d968..9c17c190 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -210,7 +210,6 @@ export class PhantomClient { submissionConfig: SubmissionConfig, account: string, ): Promise { - try { const chainTransaction = { solana: transaction }; @@ -221,13 +220,11 @@ export class PhantomClient { submissionConfig, simulationConfig: { account }, }; - - const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/augment/spending-limit`, request, { + const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/prepare/spending-limit`, request, { headers: { "Content-Type": "application/json", }, }); - return response.data; } catch (error: any) { throw new Error(`Failed to augment transaction: ${error.response?.data?.message || error.message}`); @@ -287,7 +284,6 @@ export class PhantomClient { // If we don't receive an account // At this point, we've already validated that submissionConfig and account exist for Solana if (isSolanaTransaction && params.walletType === "user-wallet") { - if (!params.account) { throw new Error("Account is required to simulate Solana transactions with spending limits"); } @@ -298,8 +294,8 @@ export class PhantomClient { encodedTransaction, this.config.organizationId, walletId, - submissionConfig, // Non-null assertion safe because we validated above - params.account, // Non-null assertion safe because we validated above + submissionConfig, // Non-null assertion safe because we validated above + params.account, // Non-null assertion safe because we validated above ); augmentedTransaction = augmentResponse.transaction; From 5e6078eb2565022164b6770c6fc0385122a64a50 Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Fri, 7 Nov 2025 18:47:24 -0800 Subject: [PATCH 20/33] Changing request path --- packages/client/src/PhantomClient.test.ts | 54 +++++++++++++++++++---- packages/client/src/PhantomClient.ts | 2 +- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 1e0b201f..f4401105 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -370,7 +370,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect(result.transaction).toBe("augmented-tx"); expect(mockAxiosPost).toHaveBeenCalledWith( - "https://api.phantom.app/prepare/spending-limit", + "https://api.phantom.app/prepare", { transaction: { solana: "original-tx-base64" }, organizationId: "org-123", @@ -516,7 +516,13 @@ describe("PhantomClient Spending Limits Integration", () => { await expect( performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, + { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + walletType: "user-wallet", + }, true, ), ).rejects.toThrow("Failed to apply spending limits for this transaction"); @@ -533,7 +539,13 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); const result = await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, + { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + walletType: "user-wallet", + }, true, ); @@ -547,7 +559,13 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); const result = await performSigning( - { walletId: "wallet-123", transaction: "0x1234", networkId: NetworkId.ETHEREUM_MAINNET, account: "0xUser", walletType: "user-wallet" }, + { + walletId: "wallet-123", + transaction: "0x1234", + networkId: NetworkId.ETHEREUM_MAINNET, + account: "0xUser", + walletType: "user-wallet", + }, true, ); @@ -567,7 +585,13 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); const result = await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, + { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + walletType: "user-wallet", + }, false, ); @@ -624,7 +648,13 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, + { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + walletType: "user-wallet", + }, true, ); @@ -654,7 +684,13 @@ describe("PhantomClient Spending Limits Integration", () => { const performSigning = client["performTransactionSigning"].bind(client); await performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", walletType: "user-wallet" }, + { + walletId: "wallet-123", + transaction: "tx", + networkId: NetworkId.SOLANA_MAINNET, + account: "UserAccount123", + walletType: "user-wallet", + }, true, ); @@ -691,7 +727,7 @@ describe("PhantomClient Spending Limits Integration", () => { expect(result.transaction).toBe("augmented-tx"); expect(mockAxiosPost).toHaveBeenCalledWith( - "https://api.phantom.app/prepare/spending-limit", + "https://api.phantom.app/prepare", expect.objectContaining({ transaction: { solana: "solana-tx-base64" }, organizationId: "org-123", @@ -720,7 +756,7 @@ describe("PhantomClient Spending Limits Integration", () => { await augmentMethod("tx-base64", "org-123", "wallet-123", submissionConfig, "UserAccount123"); expect(mockAxiosPost).toHaveBeenCalledWith( - "https://api.phantom.app/prepare/spending-limit", + "https://api.phantom.app/prepare", { transaction: { solana: "tx-base64" }, organizationId: "org-123", diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 9c17c190..cd95d548 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -220,7 +220,7 @@ export class PhantomClient { submissionConfig, simulationConfig: { account }, }; - const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/prepare/spending-limit`, request, { + const response = await this.axiosInstance.post(`${this.config.apiBaseUrl}/prepare`, request, { headers: { "Content-Type": "application/json", }, From c7cfa70bf19a32f3b14928d1d311bb3341f00e64 Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Wed, 12 Nov 2025 18:12:55 -0800 Subject: [PATCH 21/33] Removing spendingLimitConfig, and renaming endpoint --- packages/client/src/PhantomClient.test.ts | 10 ++++------ packages/client/src/PhantomClient.ts | 19 +++---------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index f4401105..00864969 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -372,9 +372,8 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/prepare", { - transaction: { solana: "original-tx-base64" }, + transaction: "original-tx-base64", // Plain string, not wrapped organizationId: "org-123", - walletId: "wallet-123", submissionConfig: solanaSubmissionConfig, simulationConfig: { account: "UserAccount123" }, }, @@ -729,10 +728,10 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/prepare", expect.objectContaining({ - transaction: { solana: "solana-tx-base64" }, + transaction: "solana-tx-base64", // Plain string, not wrapped organizationId: "org-123", - walletId: "wallet-123", submissionConfig: submissionConfig, + simulationConfig: { account: "UserAccount123" }, }), expect.any(Object), ); @@ -758,9 +757,8 @@ describe("PhantomClient Spending Limits Integration", () => { expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/prepare", { - transaction: { solana: "tx-base64" }, + transaction: "tx-base64", // Plain string, not wrapped organizationId: "org-123", - walletId: "wallet-123", submissionConfig: submissionConfig, simulationConfig: { account: "UserAccount123" }, }, diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index cd95d548..4e589040 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -203,20 +203,16 @@ export class PhantomClient { * This is phase 1 of the two-phase spending limit flow * @private */ - private async augmentWithSpendingLimit( + private async prepareTransaction( transaction: string, organizationId: string, - walletId: string, submissionConfig: SubmissionConfig, account: string, ): Promise { try { - const chainTransaction = { solana: transaction }; - const request = { - transaction: chainTransaction, + transaction, organizationId, - walletId, submissionConfig, simulationConfig: { account }, }; @@ -277,7 +273,6 @@ export class PhantomClient { // TWO-PHASE SPENDING LIMITS FLOW // Phase 1: Call wallet service to check spending limits and augment transaction if needed - let spendingLimitConfig: SpendingLimitConfig | undefined; let augmentedTransaction = encodedTransaction; // Always check spending limits for Solana transactions @@ -290,16 +285,14 @@ export class PhantomClient { try { // Call wallet service augment endpoint - const augmentResponse = await this.augmentWithSpendingLimit( + const augmentResponse = await this.prepareTransaction( encodedTransaction, this.config.organizationId, - walletId, submissionConfig, // Non-null assertion safe because we validated above params.account, // Non-null assertion safe because we validated above ); augmentedTransaction = augmentResponse.transaction; - spendingLimitConfig = augmentResponse.memoryConfigUsed; } catch (e: any) { const errorMessage = e?.message || String(e); throw new Error( @@ -314,7 +307,6 @@ export class PhantomClient { const signRequest: SignTransactionRequest & { submissionConfig?: SubmissionConfig; simulationConfig?: SimulationConfig; - spendingLimitConfig?: SpendingLimitConfig; } = { organizationId: this.config.organizationId, walletId: walletId, @@ -336,11 +328,6 @@ export class PhantomClient { }; } - // Add spending limit config if available - if (spendingLimitConfig) { - signRequest.spendingLimitConfig = spendingLimitConfig; - } - const request: SignTransaction = { method: SignTransactionMethodEnum.signTransaction, params: signRequest, From cad4b0ca969c0bb321493372db694e33d2ea08d2 Mon Sep 17 00:00:00 2001 From: Dallas Kelle Date: Thu, 13 Nov 2025 17:53:28 -0800 Subject: [PATCH 22/33] Addressing some comments --- packages/client/src/PhantomClient.ts | 2 +- packages/utils/src/network.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 4e589040..f5ce695c 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -245,7 +245,7 @@ export class PhantomClient { } // SubmissionConfig is used to: 1) submit the transaction onchain, 2) derive spending limits - const submissionConfig: SubmissionConfig | null = deriveSubmissionConfig(networkIdParam) || null; + const submissionConfig = deriveSubmissionConfig(networkIdParam); if (!submissionConfig) { throw new Error(`SubmissionConfig could not be derived for network ID: ${networkIdParam}`); diff --git a/packages/utils/src/network.ts b/packages/utils/src/network.ts index 826bc6a9..122d097f 100644 --- a/packages/utils/src/network.ts +++ b/packages/utils/src/network.ts @@ -2,16 +2,16 @@ * Network utility functions for working with blockchain network identifiers */ -export function isEthereumChain(networkId: string): boolean { - const network = networkId.split(":")[0].toLowerCase(); - return network === "eip155"; -} - export function getChainPrefix(networkId: string): string { return networkId.split(":")[0].toLowerCase(); } +export function isEthereumChain(networkId: string): boolean { + const network = getChainPrefix(networkId); + return network === "eip155"; +} + export function isSolanaChain(networkId: string): boolean { - const network = networkId.split(":")[0].toLowerCase(); + const network = getChainPrefix(networkId); return network === "solana"; -} \ No newline at end of file +} From 271580df24f3adf1d9d9ff24dd61f2df00770c86 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Mon, 17 Nov 2025 10:40:20 +0100 Subject: [PATCH 23/33] fix test --- packages/client/src/PhantomClient.test.ts | 96 +++-------------------- 1 file changed, 9 insertions(+), 87 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index 00864969..e2c234d9 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -337,7 +337,7 @@ describe("PhantomClient Spending Limits Integration", () => { jest.clearAllMocks(); }); - describe("augmentWithSpendingLimit method", () => { + describe("prepareTransaction method", () => { const spendingConfig = { usdCentsLimitPerDay: 1000, // $10.00 per day memoryAccount: "MemAcc123", @@ -359,11 +359,10 @@ describe("PhantomClient Spending Limits Integration", () => { }, }); - const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + const augmentMethod = client["prepareTransaction"].bind(client); const result = await augmentMethod( "original-tx-base64", "org-123", - "wallet-123", solanaSubmissionConfig, "UserAccount123", ); @@ -390,10 +389,10 @@ describe("PhantomClient Spending Limits Integration", () => { }, }); - const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + const augmentMethod = client["prepareTransaction"].bind(client); await expect( - augmentMethod("bad-tx", "org-123", "wallet-123", solanaSubmissionConfig, "UserAccount123"), + augmentMethod("bad-tx", "org-123", solanaSubmissionConfig, "UserAccount123"), ).rejects.toThrow("Failed to augment transaction"); }); }); @@ -620,88 +619,12 @@ describe("PhantomClient Spending Limits Integration", () => { }, }); - const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - const result = await augmentMethod("original-tx", "org-123", "wallet-123", submissionConfig, "UserAccount123"); + const augmentMethod = client["prepareTransaction"].bind(client); + const result = await augmentMethod("original-tx", "org-123", submissionConfig, "UserAccount123"); expect(result.transaction).toBe("augmented-tx-with-lighthouse-instructions"); }); - it("should include spending limit config in sign request when augment returns config", async () => { - // Mock augment endpoint to return spending limit config - mockAxiosPost.mockResolvedValueOnce({ - data: { - transaction: "augmented-tx", - simulationResult: {}, - memoryConfigUsed: { - usdCentsLimitPerDay: 1000, - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }, - }); - - mockKmsPost.mockResolvedValueOnce({ - data: { result: { transaction: "signed-tx" } }, - }); - - const performSigning = client["performTransactionSigning"].bind(client); - await performSigning( - { - walletId: "wallet-123", - transaction: "tx", - networkId: NetworkId.SOLANA_MAINNET, - account: "UserAccount123", - walletType: "user-wallet", - }, - true, - ); - - // Should include the spending limit config returned from augment endpoint - expect(mockKmsPost).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - spendingLimitConfig: { - usdCentsLimitPerDay: 1000, - memoryAccount: "MemAcc123", - memoryId: 0, - memoryBump: 255, - }, - }), - }), - ); - }); - - it("should NOT include spending limit config when service returns pass-through (no limits)", async () => { - mockAxiosPost.mockResolvedValueOnce({ - data: { transaction: "tx", simulationResult: {} }, - }); - - mockKmsPost.mockResolvedValueOnce({ - data: { result: { transaction: "signed-tx" } }, - }); - - const performSigning = client["performTransactionSigning"].bind(client); - await performSigning( - { - walletId: "wallet-123", - transaction: "tx", - networkId: NetworkId.SOLANA_MAINNET, - account: "UserAccount123", - walletType: "user-wallet", - }, - true, - ); - - // Should not include spending limit config when no limits found - expect(mockKmsPost).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.not.objectContaining({ - spendingLimitConfig: expect.anything(), - }), - }), - ); - }); }); describe("augment endpoint request structure", () => { @@ -715,11 +638,10 @@ describe("PhantomClient Spending Limits Integration", () => { data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, }); - const augmentMethod = client["augmentWithSpendingLimit"].bind(client); + const augmentMethod = client["prepareTransaction"].bind(client); const result = await augmentMethod( "solana-tx-base64", "org-123", - "wallet-123", submissionConfig, "UserAccount123", ); @@ -751,8 +673,8 @@ describe("PhantomClient Spending Limits Integration", () => { network: "mainnet", }; - const augmentMethod = client["augmentWithSpendingLimit"].bind(client); - await augmentMethod("tx-base64", "org-123", "wallet-123", submissionConfig, "UserAccount123"); + const augmentMethod = client["prepareTransaction"].bind(client); + await augmentMethod("tx-base64", "org-123", submissionConfig, "UserAccount123"); expect(mockAxiosPost).toHaveBeenCalledWith( "https://api.phantom.app/prepare", From 870f9d48dc46293cd8e68fb9dab50db226bef302 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Mon, 17 Nov 2025 11:43:23 +0100 Subject: [PATCH 24/33] move wallet type to constructor --- packages/client/src/PhantomClient.test.ts | 9 +-------- packages/client/src/PhantomClient.ts | 4 +++- packages/client/src/types.ts | 6 +----- packages/embedded-provider-core/src/auth-flow.test.ts | 1 - packages/embedded-provider-core/src/embedded-provider.ts | 2 -- packages/server-sdk/src/index.ts | 2 ++ 6 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index e2c234d9..b57ca167 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -418,7 +418,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", - walletType: "user-wallet", }, true, ); @@ -444,7 +443,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", - walletType: "user-wallet", }, false, // includeSubmissionConfig = false, but augment should still be called ); @@ -457,7 +455,7 @@ describe("PhantomClient Spending Limits Integration", () => { it("should throw error when account parameter is missing for Solana user-wallet", async () => { await expect( performSigning( - { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, walletType: "user-wallet" }, + { walletId: "wallet-123", transaction: "tx", networkId: NetworkId.SOLANA_MAINNET }, true, ), ).rejects.toThrow("Account is required to simulate Solana transactions with spending limits"); @@ -477,7 +475,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "0x1234", networkId: NetworkId.ETHEREUM_MAINNET, account: "0xUser", - walletType: "user-wallet", }, true, ); @@ -519,7 +516,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", - walletType: "user-wallet", }, true, ), @@ -542,7 +538,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", - walletType: "user-wallet", }, true, ); @@ -562,7 +557,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "0x1234", networkId: NetworkId.ETHEREUM_MAINNET, account: "0xUser", - walletType: "user-wallet", }, true, ); @@ -588,7 +582,6 @@ describe("PhantomClient Spending Limits Integration", () => { transaction: "tx", networkId: NetworkId.SOLANA_MAINNET, account: "UserAccount123", - walletType: "user-wallet", }, false, ); diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index f12cb0c8..ec866bb0 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -83,9 +83,11 @@ export class PhantomClient { private kmsApi: KMSRPCApi; private axiosInstance: AxiosInstance; public stamper?: Stamper; + private walletType: "server-wallet" | "user-wallet"; constructor(config: PhantomClientConfig, stamper?: Stamper) { this.config = config; + this.walletType = config.walletType ?? "user-wallet"; // Create axios instance this.axiosInstance = axios.create(); @@ -278,7 +280,7 @@ export class PhantomClient { // Always check spending limits for Solana transactions // If we don't receive an account // At this point, we've already validated that submissionConfig and account exist for Solana - if (isSolanaTransaction && params.walletType === "user-wallet") { + if (isSolanaTransaction && this.walletType === "user-wallet") { if (!params.account) { throw new Error("Account is required to simulate Solana transactions with spending limits"); } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 538dbbc7..a43ecfe4 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -4,6 +4,7 @@ export interface PhantomClientConfig { apiBaseUrl: string; organizationId?: string; headers?: Partial; + walletType?: "server-wallet" | "user-wallet"; } export interface CreateWalletResult { @@ -66,7 +67,6 @@ export interface SignTransactionParams { networkId: NetworkId; derivationIndex?: number; // Optional account derivation index (defaults to 0) account?: string; // Optional specific account address to use - walletType: "server-wallet" | "user-wallet"; } export interface SignAndSendTransactionParams { @@ -75,7 +75,6 @@ export interface SignAndSendTransactionParams { networkId: NetworkId; derivationIndex?: number; // Optional account derivation index (defaults to 0) account?: string; // Optional specific account address to use - walletType: "server-wallet" | "user-wallet"; } export interface GetWalletWithTagParams { @@ -136,9 +135,6 @@ export interface UserConfig { export interface SpendingLimitConfig { usdCentsLimitPerDay: number; - memoryAccount: string; - memoryId: number; - memoryBump: number; } export interface AugmentWithSpendingLimitResponse { diff --git a/packages/embedded-provider-core/src/auth-flow.test.ts b/packages/embedded-provider-core/src/auth-flow.test.ts index 0e29e4ce..355da245 100644 --- a/packages/embedded-provider-core/src/auth-flow.test.ts +++ b/packages/embedded-provider-core/src/auth-flow.test.ts @@ -1144,7 +1144,6 @@ describe("EmbeddedProvider Auth Flows", () => { networkId: NetworkId.SOLANA_MAINNET, derivationIndex: 0, account: "test-solana-address", - walletType: "user-wallet", }); expect(result.hash).toBeDefined(); expect(typeof result.blockExplorer === "string" || result.blockExplorer === undefined).toBe(true); diff --git a/packages/embedded-provider-core/src/embedded-provider.ts b/packages/embedded-provider-core/src/embedded-provider.ts index 1e00c290..ecdbf3ff 100644 --- a/packages/embedded-provider-core/src/embedded-provider.ts +++ b/packages/embedded-provider-core/src/embedded-provider.ts @@ -873,7 +873,6 @@ export class EmbeddedProvider { networkId: params.networkId, derivationIndex: derivationIndex, account, - walletType: "user-wallet" }); this.logger.info("EMBEDDED_PROVIDER", "Transaction signed successfully", { @@ -931,7 +930,6 @@ export class EmbeddedProvider { networkId: params.networkId, derivationIndex: derivationIndex, account, - walletType: "user-wallet" }); this.logger.info("EMBEDDED_PROVIDER", "Transaction signed and sent successfully", { diff --git a/packages/server-sdk/src/index.ts b/packages/server-sdk/src/index.ts index 32d6d88b..e7e34184 100644 --- a/packages/server-sdk/src/index.ts +++ b/packages/server-sdk/src/index.ts @@ -101,6 +101,7 @@ export class ServerSDK { apiBaseUrl: config.apiBaseUrl || DEFAULT_WALLET_API_URL, organizationId: config.organizationId, headers, + walletType: "server-wallet", }, stamper, ); @@ -202,6 +203,7 @@ export class ServerSDK { apiBaseUrl: this.config.apiBaseUrl || DEFAULT_WALLET_API_URL, organizationId: this.config.organizationId, headers, + walletType: "server-wallet", }, new ApiKeyStamper({ apiSecretKey: keyPair.secretKey, From b7f7a5b9c342c768001036d43af6a61b2f1dd524 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Mon, 17 Nov 2025 11:46:42 +0100 Subject: [PATCH 25/33] rename prepareTransaction to prepare --- packages/client/src/PhantomClient.test.ts | 12 ++++++------ packages/client/src/PhantomClient.ts | 11 +++-------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/client/src/PhantomClient.test.ts b/packages/client/src/PhantomClient.test.ts index b57ca167..ea3d1905 100644 --- a/packages/client/src/PhantomClient.test.ts +++ b/packages/client/src/PhantomClient.test.ts @@ -337,7 +337,7 @@ describe("PhantomClient Spending Limits Integration", () => { jest.clearAllMocks(); }); - describe("prepareTransaction method", () => { + describe("prepare method", () => { const spendingConfig = { usdCentsLimitPerDay: 1000, // $10.00 per day memoryAccount: "MemAcc123", @@ -359,7 +359,7 @@ describe("PhantomClient Spending Limits Integration", () => { }, }); - const augmentMethod = client["prepareTransaction"].bind(client); + const augmentMethod = client["prepare"].bind(client); const result = await augmentMethod( "original-tx-base64", "org-123", @@ -389,7 +389,7 @@ describe("PhantomClient Spending Limits Integration", () => { }, }); - const augmentMethod = client["prepareTransaction"].bind(client); + const augmentMethod = client["prepare"].bind(client); await expect( augmentMethod("bad-tx", "org-123", solanaSubmissionConfig, "UserAccount123"), @@ -612,7 +612,7 @@ describe("PhantomClient Spending Limits Integration", () => { }, }); - const augmentMethod = client["prepareTransaction"].bind(client); + const augmentMethod = client["prepare"].bind(client); const result = await augmentMethod("original-tx", "org-123", submissionConfig, "UserAccount123"); expect(result.transaction).toBe("augmented-tx-with-lighthouse-instructions"); @@ -631,7 +631,7 @@ describe("PhantomClient Spending Limits Integration", () => { data: { transaction: "augmented-tx", simulationResult: {}, memoryConfigUsed: {} }, }); - const augmentMethod = client["prepareTransaction"].bind(client); + const augmentMethod = client["prepare"].bind(client); const result = await augmentMethod( "solana-tx-base64", "org-123", @@ -666,7 +666,7 @@ describe("PhantomClient Spending Limits Integration", () => { network: "mainnet", }; - const augmentMethod = client["prepareTransaction"].bind(client); + const augmentMethod = client["prepare"].bind(client); await augmentMethod("tx-base64", "org-123", submissionConfig, "UserAccount123"); expect(mockAxiosPost).toHaveBeenCalledWith( diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index ec866bb0..21e0bcb6 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -200,12 +200,7 @@ export class PhantomClient { } } - /** - * Augments a transaction with spending limit enforcement instructions - * This is phase 1 of the two-phase spending limit flow - * @private - */ - private async prepareTransaction( + private async prepare( transaction: string, organizationId: string, submissionConfig: SubmissionConfig, @@ -286,8 +281,8 @@ export class PhantomClient { } try { - // Call wallet service augment endpoint - const augmentResponse = await this.prepareTransaction( + // Call wallet service prepare endpoint + const augmentResponse = await this.prepare( encodedTransaction, this.config.organizationId, submissionConfig, // Non-null assertion safe because we validated above From 35f48fd6ac8115037837970e606389f0fdd7f515 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Mon, 17 Nov 2025 11:47:44 +0100 Subject: [PATCH 26/33] rename prepareTransaction to prepare --- packages/server-sdk/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/server-sdk/src/index.ts b/packages/server-sdk/src/index.ts index e7e34184..bbd2f323 100644 --- a/packages/server-sdk/src/index.ts +++ b/packages/server-sdk/src/index.ts @@ -156,7 +156,6 @@ export class ServerSDK { networkId: params.networkId, derivationIndex: params.derivationIndex, account: params.account, - walletType: "server-wallet", }); // Parse the response to get transaction result (without hash) @@ -186,7 +185,6 @@ export class ServerSDK { networkId: params.networkId, derivationIndex: params.derivationIndex, account: params.account, - walletType: "server-wallet", }); // Parse the response to get transaction hash and explorer URL From f98719308c8413eb97fa7a53d7e5e0a71dab48e5 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Mon, 17 Nov 2025 16:28:01 +0100 Subject: [PATCH 27/33] Change code sdk actions to send amount --- .../react-sdk-demo-app/src/SDKActions.tsx | 22 ++++++++++++++++--- packages/client/src/PhantomClient.test.ts | 4 +++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/examples/react-sdk-demo-app/src/SDKActions.tsx b/examples/react-sdk-demo-app/src/SDKActions.tsx index dfe9927d..6948ea54 100644 --- a/examples/react-sdk-demo-app/src/SDKActions.tsx +++ b/examples/react-sdk-demo-app/src/SDKActions.tsx @@ -48,6 +48,9 @@ export function SDKActions() { const [isStakingSol, setIsStakingSol] = useState(false); const [isSendingCustomSol, setIsSendingCustomSol] = useState(false); const [customSolAmount, setCustomSolAmount] = useState(""); + const [customSolDestination, setCustomSolDestination] = useState( + "8dvUxPRHyHGw9W68yP73GkXCjBCjRJuLrANj9n1SXRGb", + ); const [isSendingEthMainnet, setIsSendingEthMainnet] = useState(false); const [isSendingPolygon, setIsSendingPolygon] = useState(false); @@ -278,7 +281,7 @@ export function SDKActions() { const transferInstruction = SystemProgram.transfer({ fromPubkey: new PublicKey(solanaAddress), toPubkey: new PublicKey(solanaAddress), // Self-transfer for demo - lamports: 1000, // Very small amount: 0.000001 SOL + lamports: 3500000, // small amount: 0.0035 SOL }); const messageV0 = new TransactionMessage({ @@ -472,8 +475,14 @@ export function SDKActions() { // Get recent blockhash const { blockhash } = await connection.getLatestBlockhash(); - // Target address - const targetAddress = new PublicKey("8dvUxPRHyHGw9W68yP73GkXCjBCjRJuLrANj9n1SXRGb"); + // Target address (user-provided) + let targetAddress: PublicKey; + try { + targetAddress = new PublicKey(customSolDestination); + } catch (e) { + alert("Please enter a valid Solana address for the destination."); + return; + } // USDC mint address (mainnet) const usdcMint = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); @@ -1046,6 +1055,13 @@ export function SDKActions() { onChange={e => setCustomSolAmount(e.target.value)} className="sol-input" /> + setCustomSolDestination(e.target.value)} + className="sol-input" + />