+ 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