diff --git a/packages/keychain/src/__mocks__/sharedMocks.ts b/packages/keychain/src/__mocks__/sharedMocks.ts index 6c070bc67..eb46724bd 100644 --- a/packages/keychain/src/__mocks__/sharedMocks.ts +++ b/packages/keychain/src/__mocks__/sharedMocks.ts @@ -13,7 +13,13 @@ export const mockConnection = { .fn() .mockImplementation(() => constants.StarknetChainId.SN_MAIN), estimateInvokeFee: vi.fn().mockImplementation(async () => ({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", })), }, upgrade: { diff --git a/packages/keychain/src/components/Execute.test.tsx b/packages/keychain/src/components/Execute.test.tsx index 30bb3c8c6..8d032bcf8 100644 --- a/packages/keychain/src/components/Execute.test.tsx +++ b/packages/keychain/src/components/Execute.test.tsx @@ -39,6 +39,7 @@ vi.mock("@/hooks/tokens", () => ({ error: null, })), convertTokenAmountToUSD: vi.fn(() => "$0.01"), + formatBalance: vi.fn(() => "0.01"), })); // Mock the connection hook diff --git a/packages/keychain/src/components/ExecutionContainer.test.tsx b/packages/keychain/src/components/ExecutionContainer.test.tsx index 9d1674cd0..a4585b0e2 100644 --- a/packages/keychain/src/components/ExecutionContainer.test.tsx +++ b/packages/keychain/src/components/ExecutionContainer.test.tsx @@ -20,6 +20,7 @@ vi.mock("@/hooks/tokens", () => ({ error: null, })), convertTokenAmountToUSD: vi.fn(() => "$0.01"), + formatBalance: vi.fn(() => "0.01"), })); describe("ExecutionContainer", () => { @@ -46,7 +47,13 @@ describe("ExecutionContainer", () => { it("estimates fees when transactions are provided", async () => { const estimateInvokeFee = vi.fn().mockImplementation(async () => ({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", })); await act(async () => { @@ -81,7 +88,13 @@ describe("ExecutionContainer", () => { const onSubmit = vi.fn().mockResolvedValue(undefined); const estimateInvokeFee = vi.fn().mockImplementation(async () => ({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", })); await act(async () => { @@ -139,7 +152,13 @@ describe("ExecutionContainer", () => { message: "Insufficient balance", }); const estimateInvokeFee = vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }); await act(async () => { diff --git a/packages/keychain/src/components/Fees.test.tsx b/packages/keychain/src/components/Fees.test.tsx new file mode 100644 index 000000000..1cffa4b7a --- /dev/null +++ b/packages/keychain/src/components/Fees.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FeeEstimate } from "starknet"; +import { Fees } from "./Fees"; + +vi.mock("@/hooks/tokens", () => ({ + useFeeToken: vi.fn(() => ({ + token: { + address: "0x01", + symbol: "STRK", + decimals: 18, + price: { value: 1, currency: "USD" }, + }, + isLoading: false, + error: null, + })), + convertTokenAmountToUSD: vi.fn(() => "$0.01"), + formatBalance: vi.fn(() => "0.01"), +})); + +describe("Fees", () => { + it("shows a partial paymaster label when subsidy is applied", () => { + const estimate = { + overall_fee: "0x1", + l1_gas_consumed: "0x0", + l1_gas_price: "0x0", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", + } as FeeEstimate; + + render(); + + expect( + screen.getByText("Partial paymaster subsidy applied"), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/keychain/src/components/Fees.tsx b/packages/keychain/src/components/Fees.tsx index d66204970..ac9dd7fa3 100644 --- a/packages/keychain/src/components/Fees.tsx +++ b/packages/keychain/src/components/Fees.tsx @@ -5,6 +5,7 @@ import { formatBalance, useFeeToken, } from "@/hooks/tokens"; +import { isPartialPaymaster } from "@/utils/fee"; import { ErrorAlert } from "./ErrorAlert"; import { Total } from "./Total"; @@ -19,6 +20,7 @@ export function Fees({ const [feeValue, setFeeValue] = useState(); const [usdFee, setUsdFee] = useState(); const isLoading = isEstimating || isPriceLoading; + const partialPaymaster = isPartialPaymaster(maxFee); useEffect(() => { if (isLoading || error || !token) { @@ -68,6 +70,11 @@ export function Fees({ usdValue={usdFee} isLoading={isLoading} /> + {partialPaymaster && !isLoading && ( +

+ Partial paymaster subsidy applied +

+ )} ); } diff --git a/packages/keychain/src/components/provider/upgrade.tsx b/packages/keychain/src/components/provider/upgrade.tsx index 9933143a9..d7778064b 100644 --- a/packages/keychain/src/components/provider/upgrade.tsx +++ b/packages/keychain/src/components/provider/upgrade.tsx @@ -11,61 +11,17 @@ import { addAddressPadding, Call } from "starknet"; import { ControllerError } from "@/utils/connection"; import Controller from "@/utils/controller"; import { usePostHog } from "./posthog"; - -export enum OutsideExecutionVersion { - V2, - V3, -} - -export type ControllerVersionInfo = { - version: string; - hash: string; - outsideExecutionVersion: OutsideExecutionVersion; - changes: string[]; -}; - -export const CONTROLLER_VERSIONS: ControllerVersionInfo[] = [ - { - version: "1.0.4", - hash: "0x24a9edbfa7082accfceabf6a92d7160086f346d622f28741bf1c651c412c9ab", - outsideExecutionVersion: OutsideExecutionVersion.V2, - changes: [], - }, - { - version: "1.0.5", - hash: "0x32e17891b6cc89e0c3595a3df7cee760b5993744dc8dfef2bd4d443e65c0f40", - outsideExecutionVersion: OutsideExecutionVersion.V2, - changes: ["Improved session token implementation"], - }, - { - version: "1.0.6", - hash: "0x59e4405accdf565112fe5bf9058b51ab0b0e63665d280b816f9fe4119554b77", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: [ - "Support session key message signing", - "Support session guardians", - "Improve paymaster nonce management", - ], - }, - { - version: "1.0.7", - hash: "0x3e0a04bab386eaa51a41abe93d8035dccc96bd9d216d44201266fe0b8ea1115", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: ["Unified message signature verification"], - }, - { - version: "1.0.8", - hash: "0x511dd75da368f5311134dee2356356ac4da1538d2ad18aa66d57c47e3757d59", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: ["Improved session message signature"], - }, - { - version: "1.0.9", - hash: "0x743c83c41ce99ad470aa308823f417b2141e02e04571f5c0004e743556e7faf", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: ["Wildcard session support"], - }, -]; +import { + CONTROLLER_VERSIONS, + OutsideExecutionVersion, +} from "@/utils/controller-versions"; +import type { ControllerVersionInfo } from "@/utils/controller-versions"; + +export { + CONTROLLER_VERSIONS, + OutsideExecutionVersion, +} from "@/utils/controller-versions"; +export type { ControllerVersionInfo } from "@/utils/controller-versions"; export const STABLE_CONTROLLER = CONTROLLER_VERSIONS[5]; export const BETA_CONTROLLER = CONTROLLER_VERSIONS[5]; diff --git a/packages/keychain/src/components/transaction/ConfirmTransaction.stories.tsx b/packages/keychain/src/components/transaction/ConfirmTransaction.stories.tsx index e3a9befc8..94bd0f295 100644 --- a/packages/keychain/src/components/transaction/ConfirmTransaction.stories.tsx +++ b/packages/keychain/src/components/transaction/ConfirmTransaction.stories.tsx @@ -11,7 +11,13 @@ const meta = { connection: { controller: { estimateInvokeFee: () => ({ - suggestedMaxFee: "100", + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }), hasSession: () => true, session: () => true, @@ -127,7 +133,13 @@ export const ValidationErrorFromProp: Story = { controller: { estimateInvokeFee: () => Promise.resolve({ - suggestedMaxFee: BigInt(100), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }), hasSession: () => true, session: () => true, diff --git a/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx b/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx index 8e18af806..fb581943f 100644 --- a/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx +++ b/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx @@ -21,6 +21,7 @@ vi.mock("@/hooks/tokens", () => ({ error: null, })), convertTokenAmountToUSD: vi.fn(() => "$0.01"), + formatBalance: vi.fn(() => "0.01"), })); // Mock the upgrade provider hook @@ -69,7 +70,13 @@ describe("ConfirmTransaction", () => { const mockExecute = vi.fn().mockRejectedValue(validationError); const estimateInvokeFee = vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }); await act(async () => { @@ -110,7 +117,13 @@ describe("ConfirmTransaction", () => { const mockExecute = vi.fn().mockRejectedValue(validationError); const estimateInvokeFee = vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }); await act(async () => { @@ -159,7 +172,13 @@ describe("ConfirmTransaction", () => { transaction_hash: "0xabc123", }); const estimateInvokeFee = vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }); await act(async () => { @@ -209,7 +228,13 @@ describe("ConfirmTransaction", () => { controller: { isRequestedSession: vi.fn().mockResolvedValue(true), estimateInvokeFee: vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, @@ -242,7 +267,13 @@ describe("ConfirmTransaction", () => { }); const estimateInvokeFee = vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }); await act(async () => { @@ -292,7 +323,13 @@ describe("ConfirmTransaction", () => { transaction_hash: mockTransactionHash, }); const estimateInvokeFee = vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", }); await act(async () => { diff --git a/packages/keychain/src/components/transaction/ConfirmTransaction.tsx b/packages/keychain/src/components/transaction/ConfirmTransaction.tsx index 730a22f44..a9c2f6b13 100644 --- a/packages/keychain/src/components/transaction/ConfirmTransaction.tsx +++ b/packages/keychain/src/components/transaction/ConfirmTransaction.tsx @@ -9,6 +9,12 @@ import { executeCore } from "@/utils/connection/execute"; import { useEffect, useState } from "react"; import { PageLoading } from "../Loading"; import { ErrorCode } from "@cartridge/controller-wasm"; +import { isPartialPaymaster } from "@/utils/fee"; +import { buildPartialPaymasterCalls } from "@/utils/partial-paymaster"; +import { + OutsideExecutionVersion, + resolveOutsideExecutionVersion, +} from "@/utils/controller-versions"; interface ConfirmTransactionProps { onComplete: (transaction_hash: string) => void; @@ -61,6 +67,24 @@ export function ConfirmTransaction({ } try { + if (isPartialPaymaster(maxFee)) { + const callsWithFee = buildPartialPaymasterCalls(transactions, maxFee, { + order: "append", + }); + const version = resolveOutsideExecutionVersion( + account.classHash?.(), + OutsideExecutionVersion.V3, + ); + + const { transaction_hash } = + version === OutsideExecutionVersion.V2 + ? await account.executeFromOutsideV2(callsWithFee) + : await account.executeFromOutsideV3(callsWithFee); + + onComplete(transaction_hash); + return; + } + const { transaction_hash } = await account.execute(transactions, maxFee); onComplete(transaction_hash); } catch (e) { diff --git a/packages/keychain/src/test/mocks/connection.tsx b/packages/keychain/src/test/mocks/connection.tsx index 00753b924..6a1a78df1 100644 --- a/packages/keychain/src/test/mocks/connection.tsx +++ b/packages/keychain/src/test/mocks/connection.tsx @@ -13,7 +13,13 @@ import { NavigationProvider } from "@/context/navigation"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const defaultMockController: any = { estimateInvokeFee: vi.fn().mockImplementation(async () => ({ - suggestedMaxFee: BigInt(1000), + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", })), chainId: vi.fn().mockImplementation(() => constants.StarknetChainId.SN_MAIN), } as const; diff --git a/packages/keychain/src/utils/connection/execute.test.ts b/packages/keychain/src/utils/connection/execute.test.ts index 06ceab843..0e7a2fbd7 100644 --- a/packages/keychain/src/utils/connection/execute.test.ts +++ b/packages/keychain/src/utils/connection/execute.test.ts @@ -31,11 +31,22 @@ const mockController = { executeFromOutsideV3: vi.fn(() => Promise.resolve({ transaction_hash: "0x123" }), ), - estimateInvokeFee: vi.fn(() => Promise.resolve({})), + estimateInvokeFee: vi.fn(() => + Promise.resolve({ + overall_fee: "0x64", + l1_gas_consumed: "0x1", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", + }), + ), execute: vi.fn(() => Promise.resolve({ transaction_hash: "0x456" })), trySessionExecute: vi.fn(() => Promise.resolve({ transaction_hash: "0x123" }), ), + classHash: vi.fn(() => "0x123"), }; describe("execute utils", () => { @@ -217,6 +228,86 @@ describe("execute utils", () => { }); }); + describe("partial paymaster execution", () => { + const mockNavigate = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("executes from outside when policies are authorized", async () => { + const executeFunc = execute({ navigate: mockNavigate })( + "https://example.com", + ); + const transactions: Call[] = [ + { + contractAddress: "0x123", + entrypoint: "transfer", + calldata: ["0x456", "100", "0"], + }, + ]; + + mockController.estimateInvokeFee.mockResolvedValueOnce({ + overall_fee: "0x64", + l1_gas_consumed: "0x0", + l1_gas_price: "0x0", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", + }); + mockController.hasAuthorizedPoliciesForCalls.mockResolvedValueOnce(true); + mockController.executeFromOutsideV3.mockResolvedValueOnce({ + transaction_hash: "0xabc", + }); + + const result = await executeFunc(transactions); + + expect(mockController.executeFromOutsideV3).toHaveBeenCalled(); + expect(mockController.trySessionExecute).not.toHaveBeenCalled(); + expect(result).toEqual({ + code: ResponseCodes.SUCCESS, + transaction_hash: "0xabc", + }); + }); + + it("falls back to session execution when policies are not authorized", async () => { + const executeFunc = execute({ navigate: mockNavigate })( + "https://example.com", + ); + const transactions: Call[] = [ + { + contractAddress: "0x123", + entrypoint: "transfer", + calldata: ["0x456", "100", "0"], + }, + ]; + + mockController.estimateInvokeFee.mockResolvedValueOnce({ + overall_fee: "0x64", + l1_gas_consumed: "0x0", + l1_gas_price: "0x0", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", + }); + mockController.hasAuthorizedPoliciesForCalls.mockResolvedValueOnce(false); + mockController.trySessionExecute.mockResolvedValueOnce({ + transaction_hash: "0xdef", + }); + + const result = await executeFunc(transactions); + + expect(mockController.trySessionExecute).toHaveBeenCalled(); + expect(mockController.executeFromOutsideV3).not.toHaveBeenCalled(); + expect(result).toEqual({ + code: ResponseCodes.SUCCESS, + transaction_hash: "0xdef", + }); + }); + }); + describe("session and manual execution error handling", () => { const mockNavigate = vi.fn(); diff --git a/packages/keychain/src/utils/connection/execute.ts b/packages/keychain/src/utils/connection/execute.ts index 85f414fa3..3791bcd09 100644 --- a/packages/keychain/src/utils/connection/execute.ts +++ b/packages/keychain/src/utils/connection/execute.ts @@ -9,6 +9,7 @@ import { AllowArray, Call, CallData, + FeeEstimate, InvocationsDetails, InvokeFunctionResponse, addAddressPadding, @@ -18,6 +19,12 @@ import { JsCall } from "@cartridge/controller-wasm/controller"; import { mutex } from "./sync"; import Controller from "../controller"; import { storeCallbacks, generateCallbackId } from "./callbacks"; +import { isPartialPaymaster } from "../fee"; +import { buildPartialPaymasterCalls } from "../partial-paymaster"; +import { + OutsideExecutionVersion, + resolveOutsideExecutionVersion, +} from "../controller-versions"; export type ControllerError = { code: ErrorCode; @@ -98,7 +105,41 @@ export async function executeCore( throw new Error("Controller not found"); } - const calls = normalizeCalls(transactions); + const callArray = toArray(transactions) as Call[]; + const calls = normalizeCalls(callArray); + + let feeEstimate: FeeEstimate | undefined; + try { + feeEstimate = await controller.estimateInvokeFee(callArray); + } catch { + return await controller.trySessionExecute(origin, calls, feeSource); + } + + if (isPartialPaymaster(feeEstimate)) { + const authorized = await controller.hasAuthorizedPoliciesForCalls( + origin, + callArray, + ); + + if (authorized) { + const callsWithFee = normalizeCalls( + buildPartialPaymasterCalls(callArray, feeEstimate, { + order: "append", + }), + ); + const version = resolveOutsideExecutionVersion( + controller.classHash?.(), + OutsideExecutionVersion.V3, + ); + + if (version === OutsideExecutionVersion.V2) { + return await controller.executeFromOutsideV2(callsWithFee); + } + + return await controller.executeFromOutsideV3(callsWithFee); + } + } + return await controller.trySessionExecute(origin, calls, feeSource); } diff --git a/packages/keychain/src/utils/controller-versions.ts b/packages/keychain/src/utils/controller-versions.ts new file mode 100644 index 000000000..b0d2e4c3b --- /dev/null +++ b/packages/keychain/src/utils/controller-versions.ts @@ -0,0 +1,74 @@ +import { addAddressPadding } from "starknet"; + +export enum OutsideExecutionVersion { + V2, + V3, +} + +export type ControllerVersionInfo = { + version: string; + hash: string; + outsideExecutionVersion: OutsideExecutionVersion; + changes: string[]; +}; + +export const CONTROLLER_VERSIONS: ControllerVersionInfo[] = [ + { + version: "1.0.4", + hash: "0x24a9edbfa7082accfceabf6a92d7160086f346d622f28741bf1c651c412c9ab", + outsideExecutionVersion: OutsideExecutionVersion.V2, + changes: [], + }, + { + version: "1.0.5", + hash: "0x32e17891b6cc89e0c3595a3df7cee760b5993744dc8dfef2bd4d443e65c0f40", + outsideExecutionVersion: OutsideExecutionVersion.V2, + changes: ["Improved session token implementation"], + }, + { + version: "1.0.6", + hash: "0x59e4405accdf565112fe5bf9058b51ab0b0e63665d280b816f9fe4119554b77", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: [ + "Support session key message signing", + "Support session guardians", + "Improve paymaster nonce management", + ], + }, + { + version: "1.0.7", + hash: "0x3e0a04bab386eaa51a41abe93d8035dccc96bd9d216d44201266fe0b8ea1115", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: ["Unified message signature verification"], + }, + { + version: "1.0.8", + hash: "0x511dd75da368f5311134dee2356356ac4da1538d2ad18aa66d57c47e3757d59", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: ["Improved session message signature"], + }, + { + version: "1.0.9", + hash: "0x743c83c41ce99ad470aa308823f417b2141e02e04571f5c0004e743556e7faf", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: ["Wildcard session support"], + }, +]; + +const normalizeHash = (hash: string) => addAddressPadding(hash).toLowerCase(); + +export function resolveOutsideExecutionVersion( + classHash?: string, + fallback: OutsideExecutionVersion = OutsideExecutionVersion.V3, +): OutsideExecutionVersion { + if (!classHash) { + return fallback; + } + + const normalized = normalizeHash(classHash); + const found = CONTROLLER_VERSIONS.find( + (version) => normalizeHash(version.hash) === normalized, + ); + + return found ? found.outsideExecutionVersion : fallback; +} diff --git a/packages/keychain/src/utils/controller.ts b/packages/keychain/src/utils/controller.ts index 3a3797f13..bed130c82 100644 --- a/packages/keychain/src/utils/controller.ts +++ b/packages/keychain/src/utils/controller.ts @@ -32,7 +32,7 @@ import { credentialToAuth } from "@/components/connect/types"; import { ParsedSessionPolicies, toWasmPolicies } from "@/hooks/session"; import { CredentialMetadata } from "@cartridge/ui/utils/api/cartridge"; import { DeployedAccountTransaction } from "@starknet-io/types-js"; -import { toJsFeeEstimate } from "./fee"; +import { isPartialPaymaster, toJsFeeEstimate } from "./fee"; export default class Controller { private cartridge: CartridgeAccount; @@ -260,10 +260,26 @@ export default class Controller { )) as FeeEstimate; res.unit = "FRI"; + if (isPartialPaymaster(res)) { + return res; + } + // Scale all fee estimate values by 50% (equivalent to 1.5x) // Using starknet.js addPercent pattern for consistency - const addPercent = (number: string | number, percent: number): string => { - const bigIntNum = BigInt(number); + const addPercent = ( + number: string | number | undefined, + percent: number, + ): string => { + let bigIntNum: bigint; + try { + if (number === undefined || number === null || number === "") { + bigIntNum = 0n; + } else { + bigIntNum = BigInt(number); + } + } catch { + bigIntNum = 0n; + } return (bigIntNum + (bigIntNum * BigInt(percent)) / 100n).toString(); }; diff --git a/packages/keychain/src/utils/fee.test.ts b/packages/keychain/src/utils/fee.test.ts new file mode 100644 index 000000000..a0dd125c1 --- /dev/null +++ b/packages/keychain/src/utils/fee.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { FeeEstimate } from "starknet"; +import { isPartialPaymaster } from "./fee"; + +describe("isPartialPaymaster", () => { + it("returns true when all gas fields are zero", () => { + const estimate = { + overall_fee: "0x1", + l1_gas_consumed: "0x0", + l1_gas_price: "0x0", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", + } as FeeEstimate; + + expect(isPartialPaymaster(estimate)).toBe(true); + }); + + it("returns false when any gas field is non-zero", () => { + const estimate = { + overall_fee: "0x1", + l1_gas_consumed: "0x0", + l1_gas_price: "0x1", + l2_gas_consumed: "0x0", + l2_gas_price: "0x0", + l1_data_gas_consumed: "0x0", + l1_data_gas_price: "0x0", + } as FeeEstimate; + + expect(isPartialPaymaster(estimate)).toBe(false); + }); + + it("treats missing gas fields as zero", () => { + const estimate = { + overall_fee: "0x1", + } as FeeEstimate; + + expect(isPartialPaymaster(estimate)).toBe(true); + }); +}); diff --git a/packages/keychain/src/utils/fee.ts b/packages/keychain/src/utils/fee.ts index d6fa97fa5..17d34ed49 100644 --- a/packages/keychain/src/utils/fee.ts +++ b/packages/keychain/src/utils/fee.ts @@ -1,17 +1,67 @@ import { JsFeeEstimate } from "@cartridge/controller-wasm/controller"; import { FeeEstimate } from "starknet"; +const toBigIntOrZero = (value: unknown): bigint => { + if (value === null || value === undefined) { + return 0n; + } + + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number") { + return BigInt(Math.trunc(value)); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return 0n; + } + + try { + return BigInt(trimmed); + } catch { + return 0n; + } + } + + return 0n; +}; + +const PARTIAL_PAYMASTER_FIELDS: Array = [ + "l1_gas_consumed", + "l1_gas_price", + "l2_gas_consumed", + "l2_gas_price", + "l1_data_gas_consumed", + "l1_data_gas_price", +]; + +export function isPartialPaymaster(estimate?: FeeEstimate): boolean { + if (!estimate) { + return false; + } + + return PARTIAL_PAYMASTER_FIELDS.every((field) => { + const value = estimate[field]; + return toBigIntOrZero(value) === 0n; + }); +} + export function toJsFeeEstimate(fee?: FeeEstimate): JsFeeEstimate | undefined { // If the overall_fee is 0 then it is a free txn - if (!fee || Number(fee.overall_fee) === 0) return undefined; + const overallFee = toBigIntOrZero(fee?.overall_fee); + if (!fee || overallFee === 0n) return undefined; return { - l1_gas_consumed: Number(fee.l1_gas_consumed), - l1_gas_price: Number(fee.l1_gas_price), - l2_gas_consumed: Number(fee.l2_gas_consumed), - l2_gas_price: Number(fee.l2_gas_price), - overall_fee: Number(fee.overall_fee), - l1_data_gas_consumed: Number(fee.l1_data_gas_consumed), - l1_data_gas_price: Number(fee.l1_data_gas_price), + l1_gas_consumed: Number(toBigIntOrZero(fee.l1_gas_consumed)), + l1_gas_price: Number(toBigIntOrZero(fee.l1_gas_price)), + l2_gas_consumed: Number(toBigIntOrZero(fee.l2_gas_consumed)), + l2_gas_price: Number(toBigIntOrZero(fee.l2_gas_price)), + overall_fee: Number(overallFee), + l1_data_gas_consumed: Number(toBigIntOrZero(fee.l1_data_gas_consumed)), + l1_data_gas_price: Number(toBigIntOrZero(fee.l1_data_gas_price)), }; } diff --git a/packages/keychain/src/utils/partial-paymaster.ts b/packages/keychain/src/utils/partial-paymaster.ts new file mode 100644 index 000000000..a59a82ff3 --- /dev/null +++ b/packages/keychain/src/utils/partial-paymaster.ts @@ -0,0 +1,61 @@ +import { STRK_CONTRACT_ADDRESS } from "@cartridge/ui/utils"; +import { Call, CallData, cairo, FeeEstimate } from "starknet"; + +export const PARTIAL_PAYMASTER_FEE_RECIPIENT = + "0x02d2e564dd4faa14277fefd0d8cb95e83b13c0353170eb6819ec35bf1bee8e2a"; + +const toBigIntOrZero = (value: unknown): bigint => { + if (value === null || value === undefined) { + return 0n; + } + + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number") { + return BigInt(Math.trunc(value)); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return 0n; + } + + try { + return BigInt(trimmed); + } catch { + return 0n; + } + } + + return 0n; +}; + +export function buildPartialPaymasterCalls( + calls: Call[], + feeEstimate: FeeEstimate, + options: { order?: "append" | "prepend" } = {}, +): Call[] { + const overallFee = toBigIntOrZero(feeEstimate.overall_fee); + + if (overallFee === 0n) { + return calls; + } + + const feeTransfer: Call = { + contractAddress: STRK_CONTRACT_ADDRESS, + entrypoint: "transfer", + calldata: CallData.compile({ + recipient: PARTIAL_PAYMASTER_FEE_RECIPIENT, + amount: cairo.uint256(overallFee), + }), + }; + + if (options.order === "prepend") { + return [feeTransfer, ...calls]; + } + + return [...calls, feeTransfer]; +}