diff --git a/.env.example b/.env.example index 8aaa1248a..0632ca0b0 100644 --- a/.env.example +++ b/.env.example @@ -57,11 +57,6 @@ KEEPERHUB_API_KEY= # one from 1Password ("localstack.cloud"). LOCALSTACK_AUTH_TOKEN= -PIMLICO_API_KEY= -PIMLICO_BASE_URL= -# Pimlico SimpleAccount7702 implementation address for EIP-7702 delegation -SIMPLE_ACCOUNT_7702_ADDRESS= - # AUTH config NEXT_PUBLIC_AUTH_PROVIDERS=email,github,google diff --git a/components/billing/billing-status.tsx b/components/billing/billing-status.tsx index 9fdd2fce7..5c8ba621c 100644 --- a/components/billing/billing-status.tsx +++ b/components/billing/billing-status.tsx @@ -20,6 +20,9 @@ import { } from "@/lib/web3/sponsorship-chains-meta"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; +const SPONSORED_MAINNETS = ["Ethereum", "Base", "Polygon", "Arbitrum"] as const; +const SPONSORED_TESTNETS = ["Sepolia", "Base Sepolia", "Polygon Amoy"] as const; + type OverageCharge = { periodStart: string; periodEnd: string; @@ -472,7 +475,7 @@ function GasCreditsBar({ const barColor = resolveBarColor(); return ( -
+
Gas sponsorship credits @@ -512,6 +515,7 @@ function GasCreditsBar({ style={{ width: `${percent}%` }} />
+ {isExhausted && (

Gas credits exhausted. Transactions will use your wallet's ETH for @@ -522,6 +526,51 @@ function GasCreditsBar({ ); } +function SponsoredNetworksRow(): React.ReactElement { + return ( +

+
+
+ + Sponsored networks + + + + + + +

Sponsored via Turnkey Gas Station

+
+

+ Testnets +

+

{SPONSORED_TESTNETS.join(", ")}

+
+

+ Transactions on other chains fall back to your wallet's native + balance for gas. +

+
+
+
+
+ {SPONSORED_MAINNETS.map((name) => ( + + {name} + + ))} +
+
+
+ ); +} + const OVERAGE_STATUS_VARIANT: Record< string, "default" | "secondary" | "destructive" | "outline" diff --git a/components/billing/pricing-table/index.tsx b/components/billing/pricing-table/index.tsx index d754947a1..e89921422 100644 --- a/components/billing/pricing-table/index.tsx +++ b/components/billing/pricing-table/index.tsx @@ -8,7 +8,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { BillingInterval } from "@/lib/billing/plans"; +import type { BillingInterval, PlanName } from "@/lib/billing/plans"; import { PLANS } from "@/lib/billing/plans"; import { cn } from "@/lib/utils"; import { @@ -18,34 +18,63 @@ import { import { PlanCard } from "./plan-card"; import type { PricingTableProps } from "./types"; -function formatGasCredits(cents: number): string { - return `$${(cents / 100).toFixed(0)} / mo`; -} - type ComparisonRow = { label: string; + tooltip?: { + title: string; + body: React.ReactNode; + }; free: string; pro: string; business: string; enterprise: string; - tooltip?: React.ReactNode; }; -const SPONSORED_NETWORKS_TOOLTIP: React.ReactNode = ( -
-

Supported networks

-

- Mainnets:{" "} - {SPONSORSHIP_MAINNET_NAMES.join(", ")} -

-

- Testnets:{" "} - {SPONSORSHIP_TESTNET_NAMES.join(", ")} -

-
-); +function formatGasCreditCap(planName: PlanName, capCents: number): string { + if (planName === "enterprise") { + return "Custom"; + } + return `$${(capCents / 100).toFixed(0)}/mo`; +} + +function buildSponsoredGasRow( + gasCreditCaps?: Record +): ComparisonRow { + const resolveCents = (planName: PlanName): number => + gasCreditCaps?.[planName] ?? PLANS[planName].features.gasCreditsCents; + return { + label: "Sponsored gas", + tooltip: { + title: "Sponsored via Turnkey Gas Station", + body: ( + <> +
+

+ Mainnets +

+

{SPONSORSHIP_MAINNET_NAMES.join(", ")}

+
+
+

+ Testnets +

+

{SPONSORSHIP_TESTNET_NAMES.join(", ")}

+
+

+ Monthly cap of sponsored gas credits in USD. Transactions on other + chains fall back to your wallet's native balance for gas. +

+ + ), + }, + free: formatGasCreditCap("free", resolveCents("free")), + pro: formatGasCreditCap("pro", resolveCents("pro")), + business: formatGasCreditCap("business", resolveCents("business")), + enterprise: formatGasCreditCap("enterprise", resolveCents("enterprise")), + }; +} -const COMPARISON_ROWS: ComparisonRow[] = [ +const STATIC_COMPARISON_ROWS: readonly ComparisonRow[] = [ { label: "Workflows", free: "Unlimited", @@ -60,14 +89,6 @@ const COMPARISON_ROWS: ComparisonRow[] = [ business: "All EVM", enterprise: "Custom", }, - { - label: "Sponsored gas", - free: formatGasCredits(PLANS.free.features.gasCreditsCents), - pro: formatGasCredits(PLANS.pro.features.gasCreditsCents), - business: formatGasCredits(PLANS.business.features.gasCreditsCents), - enterprise: `${formatGasCredits(PLANS.enterprise.features.gasCreditsCents)} (custom)`, - tooltip: SPONSORED_NETWORKS_TOOLTIP, - }, { label: "Triggers", free: "Standard", @@ -124,11 +145,51 @@ const COMPARISON_ROWS: ComparisonRow[] = [ business: "\u2014", enterprise: "Dedicated", }, -] as const; +]; -function ComparisonTable(): React.ReactElement { +function ComparisonRowLabel({ + row, +}: { + row: ComparisonRow; +}): React.ReactElement { + if (!row.tooltip) { + return <>{row.label}; + } + return ( + + {row.label} + + + + + +

{row.tooltip.title}

+ {row.tooltip.body} +
+
+
+ ); +} + +function ComparisonTable({ + gasCreditCaps, +}: { + gasCreditCaps?: Record; +}): React.ReactElement { const [isOpen, setIsOpen] = useState(false); + const rows: ComparisonRow[] = [ + ...STATIC_COMPARISON_ROWS.slice(0, 3), + buildSponsoredGasRow(gasCreditCaps), + ...STATIC_COMPARISON_ROWS.slice(3), + ]; + return (
- - - {row.tooltip} - - - )} - + {row.free} @@ -301,7 +344,7 @@ export function PricingTable({ />
- +

Paid tiers bill overage at the end of the cycle. Free tier caps at its diff --git a/deploy/executor/prod/values.yaml b/deploy/executor/prod/values.yaml index b4ecc3c4c..b1944742a 100644 --- a/deploy/executor/prod/values.yaml +++ b/deploy/executor/prod/values.yaml @@ -150,19 +150,6 @@ env: GAS_CREDITS_ENTERPRISE_CENTS: type: kv value: "10000" - # Gas Sponsorship (Pimlico EIP-7702) - PIMLICO_API_KEY: - type: parameterStore - name: pimlico-api-key - parameter_name: /eks/techops-prod/keeperhub/pimlico-api-key - PIMLICO_BASE_URL: - type: parameterStore - name: pimlico-base-url - parameter_name: /eks/techops-prod/keeperhub/pimlico-base-url - SIMPLE_ACCOUNT_7702_ADDRESS: - type: parameterStore - name: simple-account-7702-address - parameter_name: /eks/techops-prod/keeperhub/simple-account-7702-address SAFE_FETCH_ENFORCE: type: kv value: "true" diff --git a/deploy/executor/staging/values.yaml b/deploy/executor/staging/values.yaml index 59abf2511..3f89d1ad0 100644 --- a/deploy/executor/staging/values.yaml +++ b/deploy/executor/staging/values.yaml @@ -150,19 +150,6 @@ env: GAS_CREDITS_ENTERPRISE_CENTS: type: kv value: "10000" - # Gas Sponsorship (Pimlico EIP-7702) - PIMLICO_API_KEY: - type: parameterStore - name: pimlico-api-key - parameter_name: /eks/techops-staging/keeperhub/pimlico-api-key - PIMLICO_BASE_URL: - type: parameterStore - name: pimlico-base-url - parameter_name: /eks/techops-staging/keeperhub/pimlico-base-url - SIMPLE_ACCOUNT_7702_ADDRESS: - type: parameterStore - name: simple-account-7702-address - parameter_name: /eks/techops-staging/keeperhub/simple-account-7702-address SAFE_FETCH_ENFORCE: type: kv value: "true" diff --git a/deploy/keeperhub/prod/values.yaml b/deploy/keeperhub/prod/values.yaml index f0cedfd32..756e0e796 100644 --- a/deploy/keeperhub/prod/values.yaml +++ b/deploy/keeperhub/prod/values.yaml @@ -427,19 +427,6 @@ env: GAS_CREDITS_ENTERPRISE_CENTS: type: kv value: "10000" - # Gas Sponsorship (Pimlico EIP-7702) - PIMLICO_API_KEY: - type: parameterStore - name: pimlico-api-key - parameter_name: /eks/techops-prod/keeperhub/pimlico-api-key - PIMLICO_BASE_URL: - type: parameterStore - name: pimlico-base-url - parameter_name: /eks/techops-prod/keeperhub/pimlico-base-url - SIMPLE_ACCOUNT_7702_ADDRESS: - type: parameterStore - name: simple-account-7702-address - parameter_name: /eks/techops-prod/keeperhub/simple-account-7702-address # Internal service API keys for service-to-service auth MCP_SERVICE_API_KEY: type: parameterStore diff --git a/deploy/keeperhub/staging/values.yaml b/deploy/keeperhub/staging/values.yaml index fcf70b717..cc00dfb33 100644 --- a/deploy/keeperhub/staging/values.yaml +++ b/deploy/keeperhub/staging/values.yaml @@ -425,19 +425,6 @@ env: GAS_CREDITS_ENTERPRISE_CENTS: type: kv value: "10000" - # Gas Sponsorship (Pimlico EIP-7702) - PIMLICO_API_KEY: - type: parameterStore - name: pimlico-api-key - parameter_name: /eks/techops-staging/keeperhub/pimlico-api-key - PIMLICO_BASE_URL: - type: parameterStore - name: pimlico-base-url - parameter_name: /eks/techops-staging/keeperhub/pimlico-base-url - SIMPLE_ACCOUNT_7702_ADDRESS: - type: parameterStore - name: simple-account-7702-address - parameter_name: /eks/techops-staging/keeperhub/simple-account-7702-address # Internal service API keys for service-to-service auth MCP_SERVICE_API_KEY: type: parameterStore diff --git a/lib/agentic-wallet/policy.ts b/lib/agentic-wallet/policy.ts index 97cc891ce..598dbff4d 100644 --- a/lib/agentic-wallet/policy.ts +++ b/lib/agentic-wallet/policy.ts @@ -22,9 +22,9 @@ * giveFeedback (~$3-10 per feedback at current Ethereum gas). Both * existing payment paths are gasless from the user's perspective (Base: * EIP-3009 meta-tx, Tempo: MPP sponsor). Future work: sponsor giveFeedback - * via either Pimlico (current sponsored-tx infra in lib/web3/pimlico-config.ts) - * or Turnkey's native sponsorship. Keeping the policy strict so that - * sponsorship can be layered on without policy widening. + * via Turnkey's native gas sponsorship (lib/web3/turnkey-sponsored-tx.ts). + * Keeping the policy strict so that sponsorship can be layered on without + * policy widening. */ import type { Turnkey } from "@turnkey/sdk-server"; diff --git a/lib/db/schema-extensions.ts b/lib/db/schema-extensions.ts index 153b22cf8..1589a6951 100644 --- a/lib/db/schema-extensions.ts +++ b/lib/db/schema-extensions.ts @@ -836,10 +836,12 @@ export type NewBillingEvent = typeof billingEvents.$inferInsert; /** * Gas Sponsorship Delegations table * - * Tracks EIP-7702 delegations per organization per chain. - * Delegation is a one-time operation that upgrades an EOA to also function - * as a smart account, enabling ERC-4337 gas sponsorship via Pimlico. + * Legacy table from the ERC-4337 sponsorship path that has been replaced by + * Turnkey's native gas sponsorship (no EOA delegation required). No code + * writes to it anymore; it is retained until a dedicated drop migration runs. * + * Tracks EIP-7702 delegations per organization per chain: a one-time + * operation that upgraded an EOA to also function as a smart account. * Unique constraint on (organizationId, chainId) ensures at most one * active delegation per org per chain. * diff --git a/lib/metrics/instrumentation/safe.ts b/lib/metrics/instrumentation/safe.ts index 966960198..17dd660bf 100644 --- a/lib/metrics/instrumentation/safe.ts +++ b/lib/metrics/instrumentation/safe.ts @@ -13,6 +13,7 @@ * outcome counter and the duration histogram in one shot. */ +import type { SignerModeKind } from "@/lib/safe/signer-resolver"; import { createTimer, getMetricsCollector } from "../index"; import { MetricNames } from "../types"; @@ -123,7 +124,7 @@ export function recordSafeWithdraw(options: { * product/security stakeholders track Safe adoption over time. */ export function recordSignerMode(options: { - kind: "eoa" | "safe" | "safe-role"; + kind: SignerModeKind; chainId: number; }): void { const metrics = getMetricsCollector(); diff --git a/lib/safe/signer-resolver.ts b/lib/safe/signer-resolver.ts index 94ddf39c8..e497886d5 100644 --- a/lib/safe/signer-resolver.ts +++ b/lib/safe/signer-resolver.ts @@ -45,19 +45,31 @@ import { * `rolesModifier.execTransactionWithRole` so every call is * validated against the role's scope + allowances. */ +/** + * Canonical signer-mode discriminants. Referenced everywhere a mode is built + * or compared so the bare string literals live in exactly one place. + */ +export const SIGNER_MODE = { + EOA: "eoa", + SAFE: "safe", + SAFE_ROLE: "safe-role", +} as const; + +export type SignerModeKind = (typeof SIGNER_MODE)[keyof typeof SIGNER_MODE]; + export type SignerMode = | { - kind: "eoa"; + kind: typeof SIGNER_MODE.EOA; ownerAddress: string; } | { - kind: "safe"; + kind: typeof SIGNER_MODE.SAFE; ownerAddress: string; safeAddress: string; safeWalletId: string; } | { - kind: "safe-role"; + kind: typeof SIGNER_MODE.SAFE_ROLE; ownerAddress: string; safeAddress: string; safeWalletId: string; @@ -220,10 +232,10 @@ async function resolveSignerModeImpl( const safe = rows[0]; if (!safe) { - return { kind: "eoa", ownerAddress }; + return { kind: SIGNER_MODE.EOA, ownerAddress }; } if (safe.status !== "deployed" || !safe.isSigningActive) { - return { kind: "eoa", ownerAddress }; + return { kind: SIGNER_MODE.EOA, ownerAddress }; } // Check whether an active Zodiac Role is installed for this Safe. If so, @@ -249,7 +261,7 @@ async function resolveSignerModeImpl( const role = roleRows[0]; if (role && role.status === "active") { return { - kind: "safe-role", + kind: SIGNER_MODE.SAFE_ROLE, ownerAddress, safeAddress: safe.safeAddress, safeWalletId: safe.id, @@ -283,7 +295,7 @@ async function resolveSignerModeImpl( backfillRoleInBackground(safeForReconcile); return { - kind: "safe-role", + kind: SIGNER_MODE.SAFE_ROLE, ownerAddress, safeAddress: safe.safeAddress, safeWalletId: safe.id, @@ -294,7 +306,7 @@ async function resolveSignerModeImpl( } return { - kind: "safe", + kind: SIGNER_MODE.SAFE, ownerAddress, safeAddress: safe.safeAddress, safeWalletId: safe.id, @@ -336,8 +348,8 @@ export async function resolveWalletAndSignerMode( */ export type ParsedWeb3Connection = | { kind: "default" } - | { kind: "eoa" } - | { kind: "safe"; safeWalletId: string }; + | { kind: typeof SIGNER_MODE.EOA } + | { kind: typeof SIGNER_MODE.SAFE; safeWalletId: string }; export function parseWeb3Connection( value: string | null | undefined @@ -345,8 +357,8 @@ export function parseWeb3Connection( if (!value || value === "default") { return { kind: "default" }; } - if (value === "eoa") { - return { kind: "eoa" }; + if (value === SIGNER_MODE.EOA) { + return { kind: SIGNER_MODE.EOA }; } if (value.startsWith("safe:")) { const safeWalletId = value.slice("safe:".length); @@ -355,7 +367,7 @@ export function parseWeb3Connection( `Invalid web3Connection value '${value}': safe id is empty` ); } - return { kind: "safe", safeWalletId }; + return { kind: SIGNER_MODE.SAFE, safeWalletId }; } throw new Error(`Invalid web3Connection value '${value}'`); } @@ -391,12 +403,12 @@ export async function resolveSignerForNode( return resolveSignerMode(input.organizationId, input.chainId); } - if (parsed.kind === "eoa") { + if (parsed.kind === SIGNER_MODE.EOA) { const ownerAddress = normalizeAddressForStorage( await getOrganizationWalletAddress(input.organizationId) ); - recordSignerMode({ kind: "eoa", chainId: input.chainId }); - return { kind: "eoa", ownerAddress }; + recordSignerMode({ kind: SIGNER_MODE.EOA, chainId: input.chainId }); + return { kind: SIGNER_MODE.EOA, ownerAddress }; } // safe: @@ -459,9 +471,9 @@ export async function resolveSignerForNode( const role = roleRows[0]; if (role && role.status === "active") { - recordSignerMode({ kind: "safe-role", chainId: input.chainId }); + recordSignerMode({ kind: SIGNER_MODE.SAFE_ROLE, chainId: input.chainId }); return { - kind: "safe-role", + kind: SIGNER_MODE.SAFE_ROLE, ownerAddress, safeAddress: safe.safeAddress, safeWalletId: safe.id, @@ -486,9 +498,9 @@ export async function resolveSignerForNode( isSigningActive: safe.isSigningActive, } as SafeWallet; backfillRoleInBackground(safeForReconcile); - recordSignerMode({ kind: "safe-role", chainId: input.chainId }); + recordSignerMode({ kind: SIGNER_MODE.SAFE_ROLE, chainId: input.chainId }); return { - kind: "safe-role", + kind: SIGNER_MODE.SAFE_ROLE, ownerAddress, safeAddress: safe.safeAddress, safeWalletId: safe.id, @@ -498,9 +510,9 @@ export async function resolveSignerForNode( }; } - recordSignerMode({ kind: "safe", chainId: input.chainId }); + recordSignerMode({ kind: SIGNER_MODE.SAFE, chainId: input.chainId }); return { - kind: "safe", + kind: SIGNER_MODE.SAFE, ownerAddress, safeAddress: safe.safeAddress, safeWalletId: safe.id, diff --git a/lib/web3/chainlink-feeds.ts b/lib/web3/chainlink-feeds.ts index cdfab72ce..ee650abb2 100644 --- a/lib/web3/chainlink-feeds.ts +++ b/lib/web3/chainlink-feeds.ts @@ -14,7 +14,7 @@ const ETH_USD_FEEDS: Record = { }; /** - * Known testnet chain IDs where Pimlico doesn't charge for sponsorship. + * Known testnet chain IDs that are not charged for gas sponsorship. */ const TESTNET_CHAIN_IDS: ReadonlySet = new Set([ 11_155_111, // Sepolia diff --git a/lib/web3/eip7702-delegation.ts b/lib/web3/eip7702-delegation.ts deleted file mode 100644 index 95aec8165..000000000 --- a/lib/web3/eip7702-delegation.ts +++ /dev/null @@ -1,105 +0,0 @@ -import "server-only"; -import { and, eq } from "drizzle-orm"; -import type { Address, Hex } from "viem"; -import { createPublicClient, http } from "viem"; -import { db } from "@/lib/db"; -import { gasSponsorshipDelegations } from "@/lib/db/schema-extensions"; -import { getSimpleAccount7702Address } from "@/lib/web3/pimlico-config"; - -/** - * Check if an EOA already has EIP-7702 delegation active on a given chain. - * Looks at on-chain code at the EOA address -- if bytecode exists, - * the delegation is already in place. - */ -async function checkOnChainDelegation( - rpcUrl: string, - walletAddress: Address -): Promise { - const client = createPublicClient({ - transport: http(rpcUrl), - }); - - const code = await client.getCode({ address: walletAddress }); - // EIP-7702 delegated EOAs have a small delegation designator bytecode - return code !== undefined && code !== "0x"; -} - -/** - * Check if we have a DB record of a successful delegation for this org+chain. - */ -async function checkDbDelegation( - organizationId: string, - chainId: number -): Promise { - const records = await db - .select() - .from(gasSponsorshipDelegations) - .where( - and( - eq(gasSponsorshipDelegations.organizationId, organizationId), - eq(gasSponsorshipDelegations.chainId, chainId), - eq(gasSponsorshipDelegations.status, "active") - ) - ) - .limit(1); - - return records.length > 0; -} - -/** - * Record the EIP-7702 delegation in the database if not already tracked. - * - * EIP-7702 delegation is handled inline by permissionless.js -- it attaches - * the authorization to the first UserOperation when the account isn't yet - * delegated. This function checks on-chain state after the fact and records - * it in our DB for fast lookups. - * - * This is safe to call concurrently and non-blocking (fire-and-forget). - */ -export async function recordDelegationIfNeeded( - organizationId: string, - chainId: number, - rpcUrl: string, - walletAddress: Address -): Promise { - const hasDbRecord = await checkDbDelegation(organizationId, chainId); - if (hasDbRecord) { - return; - } - - const hasOnChainDelegation = await checkOnChainDelegation( - rpcUrl, - walletAddress - ); - - if (hasOnChainDelegation) { - await recordDelegation( - organizationId, - walletAddress, - chainId, - "0x" as Hex, - "active" - ); - } -} - -async function recordDelegation( - organizationId: string, - walletAddress: Address, - chainId: number, - delegationTxHash: Hex, - status: string -): Promise { - await db - .insert(gasSponsorshipDelegations) - .values({ - organizationId, - walletAddress, - chainId, - delegationTxHash, - implementationAddress: getSimpleAccount7702Address(), - status, - delegatedAt: new Date(), - }) - .onConflictDoNothing(); -} diff --git a/lib/web3/pimlico-config.ts b/lib/web3/pimlico-config.ts deleted file mode 100644 index 029565b20..000000000 --- a/lib/web3/pimlico-config.ts +++ /dev/null @@ -1,39 +0,0 @@ -import "server-only"; -import type { Address } from "viem"; -import { SPONSORSHIP_CHAIN_IDS } from "./sponsorship-chains-meta"; - -function getPimlicoBaseUrl(): string { - const url = process.env.PIMLICO_BASE_URL; - if (!url) { - throw new Error("PIMLICO_BASE_URL not configured"); - } - return url; -} - -export const SUPPORTED_SPONSORSHIP_CHAINS: ReadonlySet = - SPONSORSHIP_CHAIN_IDS; - -export function isSponsorshipSupported(chainId: number): boolean { - return SUPPORTED_SPONSORSHIP_CHAINS.has(chainId); -} - -/** - * SimpleAccount7702 implementation address for EIP-7702 delegation. - * Must match the default `accountLogicAddress` in permissionless.js's - * `toSimpleSmartAccount` -- deployed by Pimlico on all supported chains. - */ -export function getSimpleAccount7702Address(): Address { - const address = process.env.SIMPLE_ACCOUNT_7702_ADDRESS; - if (!address) { - throw new Error("SIMPLE_ACCOUNT_7702_ADDRESS not configured"); - } - return address as Address; -} - -export function getPimlicoUrl(chainId: number): string { - const apiKey = process.env.PIMLICO_API_KEY; - if (!apiKey) { - throw new Error("PIMLICO_API_KEY not configured"); - } - return `${getPimlicoBaseUrl()}/${chainId}/rpc?apikey=${apiKey}`; -} diff --git a/lib/web3/sponsored-client.ts b/lib/web3/sponsored-client.ts index 345640041..355dab8fa 100644 --- a/lib/web3/sponsored-client.ts +++ b/lib/web3/sponsored-client.ts @@ -1,139 +1,66 @@ import "server-only"; -import { eq } from "drizzle-orm"; -import { createSmartAccountClient } from "permissionless"; -import { to7702SimpleSmartAccount } from "permissionless/accounts"; -import { createPimlicoClient } from "permissionless/clients/pimlico"; -import type { Address, LocalAccount, PublicClient } from "viem"; -import { createPublicClient, defineChain, http } from "viem"; -import { entryPoint08Address } from "viem/account-abstraction"; +import { and, eq } from "drizzle-orm"; +import type { Address } from "viem"; import { db } from "@/lib/db"; -import { chains } from "@/lib/db/schema"; -import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { recordDelegationIfNeeded } from "@/lib/web3/eip7702-delegation"; -import { - getPimlicoUrl, - isSponsorshipSupported, -} from "@/lib/web3/pimlico-config"; -import { clampSponsoredFees } from "@/lib/web3/sponsored-fee-clamp"; -import { createTurnkeyViemAccount } from "@/lib/web3/turnkey-viem-account"; - -const LOG_PREFIX = "[Sponsorship]"; - -type SponsoredClientResult = { - // biome-ignore lint/suspicious/noExplicitAny: SmartAccountClient generic signature is deeply nested across permissionless.js types - smartAccountClient: any; - smartAccount: { isDeployed: () => Promise }; - account: LocalAccount; - publicClient: PublicClient; - walletAddress: Address; - chainId: number; -}; +import { organizationWallets } from "@/lib/db/schema-extensions"; +import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config"; /** - * Creates a sponsored smart account client for an organization. - * - * This: - * 1. Creates a viem account backed by Turnkey signing - * 2. Creates a Pimlico-sponsored smart account client with EIP-7702 support + * Sponsorship preflight: resolves the organization's active wallet and + * returns the Turnkey identifiers needed by the sponsored transaction + * manager. * - * Returns the smart account client plus the account/publicClient needed for - * callers to manually sign EIP-7702 authorization on first transaction. + * Returns null when sponsorship cannot be set up so callers fall back + * to direct signing. Reasons for null: + * - chain is not in the Turnkey Gas Station allowlist + * - the org has no active wallet + * - the wallet row is missing its Turnkey sub-organization id * - * Returns null if sponsorship cannot be set up (unsupported chain, etc). - * Callers should fall back to direct signing. + * This file intentionally does NOT call Turnkey itself -- it only assembles + * the parameters the manager will pass to ethSendTransaction. Keeping the + * preflight DB read separate from the API call lets the manager stay + * agnostic of how the wallet was provisioned. */ +export type SponsoredClientResult = { + subOrgId: string; + walletAddress: Address; + chainId: number; +}; + export async function createSponsoredClient( organizationId: string, - chainId: number, - rpcUrl: string + chainId: number ): Promise { if (!isSponsorshipSupported(chainId)) { return null; } - try { - const { account, walletRecord } = - await createTurnkeyViemAccount(organizationId); - const walletAddress = walletRecord.walletAddress as Address; - const chainRecord = await db.query.chains.findFirst({ - where: eq(chains.chainId, chainId), - }); - - const chainName = chainRecord?.name ?? `Chain ${chainId}`; - const chainSymbol = chainRecord?.symbol ?? "ETH"; + const rows = await db + .select({ + walletAddress: organizationWallets.walletAddress, + turnkeySubOrgId: organizationWallets.turnkeySubOrgId, + }) + .from(organizationWallets) + .where( + and( + eq(organizationWallets.organizationId, organizationId), + eq(organizationWallets.isActive, true) + ) + ) + .limit(1); - const chain = defineChain({ - id: chainId, - name: chainName, - nativeCurrency: { name: chainSymbol, symbol: chainSymbol, decimals: 18 }, - rpcUrls: { - default: { http: [rpcUrl] }, - }, - }); - - const publicClient = createPublicClient({ - chain, - transport: http(rpcUrl), - }); - - const pimlicoUrl = getPimlicoUrl(chainId); - - const pimlicoClient = createPimlicoClient({ - chain, - transport: http(pimlicoUrl), - entryPoint: { - address: entryPoint08Address, - version: "0.8", - }, - }); - - const smartAccount = await to7702SimpleSmartAccount({ - client: publicClient, - owner: account, - }); - - const gasPrices = await pimlicoClient.getUserOperationGasPrice(); - const sponsoredFees = clampSponsoredFees(gasPrices.fast); - - const smartAccountClient = createSmartAccountClient({ - chain, - account: smartAccount, - client: publicClient, - bundlerTransport: http(pimlicoUrl), - paymaster: pimlicoClient, - userOperation: { - estimateFeesPerGas: async () => sponsoredFees, - }, - }); - - // Record delegation in DB if first time (non-blocking) - recordDelegationIfNeeded( - organizationId, - chainId, - rpcUrl, - walletAddress - ).catch((error: unknown) => { - logSystemError( - ErrorCategory.TRANSACTION, - `${LOG_PREFIX} Failed to record delegation`, - error - ); - }); + const wallet = rows[0]; + if (!wallet) { + return null; + } - return { - smartAccountClient, - smartAccount, - account, - publicClient, - walletAddress, - chainId, - }; - } catch (error) { - logSystemError( - ErrorCategory.TRANSACTION, - `${LOG_PREFIX} Failed to create sponsored client`, - error - ); + if (wallet.turnkeySubOrgId === null) { return null; } + + return { + subOrgId: wallet.turnkeySubOrgId, + walletAddress: wallet.walletAddress as Address, + chainId, + }; } diff --git a/lib/web3/sponsored-fee-clamp.ts b/lib/web3/sponsored-fee-clamp.ts deleted file mode 100644 index 2f1b613b3..000000000 --- a/lib/web3/sponsored-fee-clamp.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Enforce the EIP-1559 invariant maxFeePerGas >= maxPriorityFeePerGas - * on a fee pair before handing it to a userOperation. - * - * Pimlico's getUserOperationGasPrice().fast has been observed on Sepolia - * returning maxPriorityFeePerGas > maxFeePerGas. Defensive clamp: lift - * maxFeePerGas to the priority value when violated. The on-chain cost - * paid is still baseFee + tip, only the cap moves. - */ -export function clampSponsoredFees(fees: { - maxFeePerGas: bigint; - maxPriorityFeePerGas: bigint; -}): { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } { - if (fees.maxFeePerGas < fees.maxPriorityFeePerGas) { - return { - maxFeePerGas: fees.maxPriorityFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - }; - } - return fees; -} diff --git a/lib/web3/sponsored-transaction-manager.ts b/lib/web3/sponsored-transaction-manager.ts index a0dc26297..a2072f919 100644 --- a/lib/web3/sponsored-transaction-manager.ts +++ b/lib/web3/sponsored-transaction-manager.ts @@ -1,5 +1,5 @@ import "server-only"; -import type { Address, Hex } from "viem"; +import type { Hex } from "viem"; import { createPublicClient, encodeFunctionData, http } from "viem"; import { checkGasCredits, @@ -10,9 +10,10 @@ import { ErrorCategory, logSystemError } from "@/lib/logging"; import { getMetricsCollector } from "@/lib/metrics"; import { MetricNames } from "@/lib/metrics/types"; import { isTestnetChain } from "@/lib/web3/chainlink-feeds"; -import { isSponsorshipSupported } from "@/lib/web3/pimlico-config"; import { createSponsoredClient } from "@/lib/web3/sponsored-client"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; +import { submitTurnkeySponsoredTransaction } from "@/lib/web3/turnkey-sponsored-tx"; +import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config"; type SponsoredTransactionResult = { success: true; @@ -49,11 +50,12 @@ type SponsoredContractTxParams = { }; /** - * Attempt to execute a transaction via gas sponsorship (ERC-4337 + Pimlico). + * Attempt to execute a transaction via Turnkey Gas Station sponsorship. * * Returns the result if sponsorship succeeds, or null if sponsorship is - * unavailable (unsupported chain, no credits, client creation failed). - * Callers should fall back to direct signing when null is returned. + * unavailable (unsupported chain, non-Turnkey wallet, credits exhausted, + * Turnkey rejected the activity). Callers should fall back to direct + * signing when null is returned. */ export async function executeSponsoredTransaction( params: SponsoredTxParams @@ -73,44 +75,37 @@ export async function executeSponsoredTransaction( const client = await createSponsoredClient( params.organizationId, - params.chainId, - params.rpcUrl + params.chainId ); if (client === null) { return null; } - try { - const txHash: Hex = await client.smartAccountClient.sendTransaction({ - to: params.to as Address, - value: params.value ?? BigInt(0), - data: params.data ?? ("0x" as Hex), - }); + const submitResult = await submitTurnkeySponsoredTransaction({ + subOrgId: client.subOrgId, + walletAddress: client.walletAddress, + chainId: params.chainId, + to: params.to, + value: params.value, + data: params.data, + }); - return await finalizeSponsoredTx( - txHash, - params.rpcUrl, - params.organizationId, - params.chainId, - params.executionId - ); - } catch (error) { - logSystemError( - ErrorCategory.TRANSACTION, - "[Sponsorship] Sponsored transaction failed, falling back to direct signing", - error instanceof Error ? error : new Error(String(error)), - { - organizationId: params.organizationId, - chainId: params.chainId.toString(), - } - ); + if (submitResult === null) { return null; } + + return await finalizeSponsoredTx( + submitResult.txHash, + params.rpcUrl, + params.organizationId, + params.chainId, + params.executionId + ); } /** - * Attempt to execute a contract call via gas sponsorship. + * Attempt to execute a contract call via Turnkey Gas Station sponsorship. * * Same semantics as executeSponsoredTransaction -- returns null on failure * so callers can fall back to direct signing. @@ -133,51 +128,48 @@ export async function executeSponsoredContractTransaction( const client = await createSponsoredClient( params.organizationId, - params.chainId, - params.rpcUrl + params.chainId ); if (client === null) { return null; } - try { - const callData = encodeFunctionData({ - abi: params.abi, - functionName: params.functionName, - args: params.args, - }); + const callData = encodeFunctionData({ + abi: params.abi, + functionName: params.functionName, + args: params.args, + }); - const txHash: Hex = await client.smartAccountClient.sendTransaction({ - to: params.to as Address, - value: params.value ?? BigInt(0), - data: callData, - }); + const submitResult = await submitTurnkeySponsoredTransaction({ + subOrgId: client.subOrgId, + walletAddress: client.walletAddress, + chainId: params.chainId, + to: params.to, + value: params.value, + data: callData, + }); - return await finalizeSponsoredTx( - txHash, - params.rpcUrl, - params.organizationId, - params.chainId, - params.executionId - ); - } catch (error) { - logSystemError( - ErrorCategory.TRANSACTION, - "[Sponsorship] Sponsored contract call failed, falling back to direct signing", - error instanceof Error ? error : new Error(String(error)), - { - organizationId: params.organizationId, - chainId: params.chainId.toString(), - } - ); + if (submitResult === null) { return null; } + + return await finalizeSponsoredTx( + submitResult.txHash, + params.rpcUrl, + params.organizationId, + params.chainId, + params.executionId + ); } /** * Wait for receipt, record gas usage, and build the result. - * Skips billing on testnets (Pimlico doesn't charge for testnet sponsorship). + * + * Turnkey pays the gas, so the org's wallet is never charged for the + * underlying tx -- but the on-chain receipt still reports gasUsed and + * effectiveGasPrice, which is what we meter against the org's gas-credit + * balance. Billing is skipped on testnets. */ async function finalizeSponsoredTx( txHash: Hex, diff --git a/lib/web3/sponsorship-chains-meta.ts b/lib/web3/sponsorship-chains-meta.ts index 6192253a2..bc28b4b40 100644 --- a/lib/web3/sponsorship-chains-meta.ts +++ b/lib/web3/sponsorship-chains-meta.ts @@ -4,19 +4,20 @@ export type SponsorshipChain = { isTestnet: boolean; }; +// Chains where Turnkey's native Transaction Management (Gas Station) can sign +// and sponsor a transaction. This is the single source of truth shared by the +// runtime sponsorship preflight and the billing UI, so the two cannot drift. +// Turnkey supports four mainnets and their canonical testnets; Optimism and +// BNB are intentionally absent because the Gas Station does not cover them. export const SPONSORSHIP_CHAINS: readonly SponsorshipChain[] = [ { chainId: 1, name: "Ethereum", isTestnet: false }, - { chainId: 10, name: "Optimism", isTestnet: false }, - { chainId: 56, name: "BNB Chain", isTestnet: false }, { chainId: 137, name: "Polygon", isTestnet: false }, { chainId: 8453, name: "Base", isTestnet: false }, { chainId: 42_161, name: "Arbitrum One", isTestnet: false }, - { chainId: 97, name: "BNB Testnet", isTestnet: true }, + { chainId: 11_155_111, name: "Sepolia", isTestnet: true }, { chainId: 80_002, name: "Polygon Amoy", isTestnet: true }, { chainId: 84_532, name: "Base Sepolia", isTestnet: true }, { chainId: 421_614, name: "Arbitrum Sepolia", isTestnet: true }, - { chainId: 11_155_111, name: "Sepolia", isTestnet: true }, - { chainId: 11_155_420, name: "OP Sepolia", isTestnet: true }, ]; export const SPONSORSHIP_CHAIN_IDS: ReadonlySet = new Set( diff --git a/lib/web3/turnkey-revert.ts b/lib/web3/turnkey-revert.ts new file mode 100644 index 000000000..c490f7384 --- /dev/null +++ b/lib/web3/turnkey-revert.ts @@ -0,0 +1,110 @@ +import "server-only"; +import type { Hex } from "viem"; + +/** + * Turnkey-supplied revert information for a sponsored transaction. + * + * Shape mirrors `v1RevertChainEntry` from @turnkey/sdk-server but is + * redeclared here so the rest of the codebase doesn't have to import + * the SDK's internal types. + */ +export type RevertChainEntry = { + address?: string; + errorType?: string; + displayMessage?: string; + native?: { + nativeType?: string; + message?: string; + }; + custom?: { + errorName?: string; + paramsJson?: string; + }; + unknown?: { + selector?: string; + data?: string; + }; +}; + +/** + * Thrown when Turnkey reports that a sponsored transaction was broadcast + * to the network and then reverted on-chain. The presence of `txHash` + * means the transaction is already mined; callers MUST NOT fall back to + * direct signing because the underlying call would just revert again. + * + * Pre-broadcast failures (policy denial, gas-cap exhaustion, simulation + * errors) do NOT produce this error -- they yield `null` from the + * sponsorship wrapper so callers can fall through to direct signing. + */ +export class SponsoredTxRevertError extends Error { + readonly kind = "sponsored-tx-revert" as const; + readonly txHash: Hex; + readonly sendTransactionStatusId: string; + readonly revertChain: readonly RevertChainEntry[]; + + constructor(opts: { + message: string; + txHash: Hex; + sendTransactionStatusId: string; + revertChain: readonly RevertChainEntry[]; + }) { + super(opts.message); + this.name = "SponsoredTxRevertError"; + this.txHash = opts.txHash; + this.sendTransactionStatusId = opts.sendTransactionStatusId; + this.revertChain = opts.revertChain; + } +} + +export function isSponsoredTxRevertError( + err: unknown +): err is SponsoredTxRevertError { + return ( + err instanceof Error && + (err as { kind?: string }).kind === "sponsored-tx-revert" + ); +} + +/** + * Render a Turnkey revert chain into a single human-readable string. + * + * Walks the chain outermost-to-innermost (the order Turnkey returns). + * For each entry, prefer the structured native/custom decoding over the + * raw display message. Falls back to "execution reverted" if no entry + * carries usable detail. + */ +export function formatRevertChain(chain: readonly RevertChainEntry[]): string { + if (chain.length === 0) { + return "execution reverted"; + } + + const lines: string[] = []; + for (const entry of chain) { + const detail = describeEntry(entry); + const location = entry.address ? ` at ${entry.address}` : ""; + lines.push(`${detail}${location}`); + } + return lines.join(" -> "); +} + +function describeEntry(entry: RevertChainEntry): string { + if (entry.custom?.errorName) { + const params = entry.custom.paramsJson + ? `(${entry.custom.paramsJson})` + : "()"; + return `${entry.custom.errorName}${params}`; + } + if (entry.native?.message) { + return entry.native.message; + } + if (entry.native?.nativeType === "panic") { + return "panic"; + } + if (entry.unknown?.selector) { + return `unknown error (selector ${entry.unknown.selector})`; + } + if (entry.displayMessage) { + return entry.displayMessage; + } + return "execution reverted"; +} diff --git a/lib/web3/turnkey-sponsored-tx.ts b/lib/web3/turnkey-sponsored-tx.ts new file mode 100644 index 000000000..d54489765 --- /dev/null +++ b/lib/web3/turnkey-sponsored-tx.ts @@ -0,0 +1,204 @@ +import "server-only"; +import type { Hex } from "viem"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; +import { getTurnkeyClientForOrg } from "@/lib/turnkey/agentic-wallet"; +import { + formatRevertChain, + type RevertChainEntry, + SponsoredTxRevertError, +} from "@/lib/web3/turnkey-revert"; +import { toCaip2 } from "@/lib/web3/turnkey-sponsorship-config"; + +/** + * Wrapper around Turnkey's Gas Station / Transaction Management API. + * + * Turnkey's native sponsorship is NOT an ERC-4337 paymaster. It is a single + * activity (`ethSendTransaction` with `sponsor: true`) that signs the + * transaction with the sub-org's wallet, fills gas from Turnkey's Gas + * Station, and broadcasts. The activity returns a `sendTransactionStatusId` + * which we poll until the transaction is broadcast and a hash is available. + * + * If the chain is not supported, or the polling deadline expires, callers + * receive null and should fall back to direct signing. + */ + +const STATUS_POLL_INTERVAL_MS = 1000; +const STATUS_POLL_TIMEOUT_MS = 30_000; + +const TERMINAL_FAILURE_STATUSES = new Set([ + "TRANSACTION_STATUS_FAILED", + "TRANSACTION_STATUS_REJECTED", + "TRANSACTION_STATUS_TIMEOUT", + "TRANSACTION_STATUS_REVERTED", +]); + +const TERMINAL_SUCCESS_STATUSES = new Set([ + "TRANSACTION_STATUS_BROADCASTED", + "TRANSACTION_STATUS_CONFIRMED", + "TRANSACTION_STATUS_FINALIZED", +]); + +export type TurnkeySponsoredTxParams = { + subOrgId: string; + walletAddress: string; + chainId: number; + to: string; + value?: bigint; + data?: Hex; +}; + +export type TurnkeySponsoredTxResult = { + txHash: Hex; + sendTransactionStatusId: string; +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Submit a sponsored EVM transaction via Turnkey Gas Station and wait + * for the broadcast tx hash. Returns null on any failure so callers can + * fall back to direct signing. + */ +export async function submitTurnkeySponsoredTransaction( + params: TurnkeySponsoredTxParams +): Promise { + const caip2 = toCaip2(params.chainId); + if (caip2 === null) { + return null; + } + + const turnkey = getTurnkeyClientForOrg(params.subOrgId); + const client = turnkey.apiClient(); + + let statusId: string; + try { + const submitResponse = await client.ethSendTransaction({ + organizationId: params.subOrgId, + from: params.walletAddress, + sponsor: true, + // Turnkey confirmed Arbitrum (eip155:42161) is supported on mainnet, + // but the SDK v5.2.0 CAIP-2 enum has not been regenerated yet. Widen + // the type until the SDK catches up; remove this cast once the enum + // includes 42161. + // biome-ignore lint/suspicious/noExplicitAny: SDK CAIP-2 enum lags Turnkey's confirmed chain coverage + caip2: caip2 as any, + to: params.to, + value: params.value === undefined ? undefined : params.value.toString(), + data: params.data, + }); + + statusId = submitResponse.sendTransactionStatusId; + } catch (error) { + logSystemError( + ErrorCategory.EXTERNAL_SERVICE, + "[Turnkey Sponsorship] ethSendTransaction failed", + error, + { + service: "turnkey", + chain_id: params.chainId.toString(), + } + ); + return null; + } + + const txHash = await pollForTxHash(params.subOrgId, statusId); + if (txHash === null) { + return null; + } + + return { txHash, sendTransactionStatusId: statusId }; +} + +async function pollForTxHash( + subOrgId: string, + sendTransactionStatusId: string +): Promise { + const turnkey = getTurnkeyClientForOrg(subOrgId); + const client = turnkey.apiClient(); + const deadline = Date.now() + STATUS_POLL_TIMEOUT_MS; + + while (Date.now() < deadline) { + let response: Awaited>; + try { + response = await client.getSendTransactionStatus({ + organizationId: subOrgId, + sendTransactionStatusId, + }); + } catch (error) { + logSystemError( + ErrorCategory.EXTERNAL_SERVICE, + "[Turnkey Sponsorship] getSendTransactionStatus failed", + error, + { + service: "turnkey", + send_transaction_status_id: sendTransactionStatusId, + } + ); + return null; + } + + const hash = response.eth?.txHash; + const hasFailure = + TERMINAL_FAILURE_STATUSES.has(response.txStatus) || + Boolean(response.txError) || + Boolean(response.error); + + if (hasFailure) { + // Post-broadcast revert: txHash is set, the underlying call is already + // on-chain. Throw a typed error carrying Turnkey's structured revert + // chain so callers can surface the real revert reason and skip the + // direct-signing fallback (which would just revert again). + if (hash !== undefined && hash !== "") { + const revertChain = (response.error?.eth?.revertChain ?? + []) as readonly RevertChainEntry[]; + const message = response.txError ?? formatRevertChain(revertChain); + throw new SponsoredTxRevertError({ + message, + txHash: hash as Hex, + sendTransactionStatusId, + revertChain, + }); + } + + // Pre-broadcast failure (policy denial, gas-cap exhaustion, simulation + // error). Nothing happened on-chain; return null so the caller falls + // back to direct signing. + logSystemError( + ErrorCategory.EXTERNAL_SERVICE, + "[Turnkey Sponsorship] Transaction terminated before broadcast", + new Error(response.txError ?? response.txStatus), + { + service: "turnkey", + send_transaction_status_id: sendTransactionStatusId, + tx_status: response.txStatus, + } + ); + return null; + } + + if ( + TERMINAL_SUCCESS_STATUSES.has(response.txStatus) && + hash !== undefined && + hash !== "" + ) { + return hash as Hex; + } + + await sleep(STATUS_POLL_INTERVAL_MS); + } + + logSystemError( + ErrorCategory.EXTERNAL_SERVICE, + "[Turnkey Sponsorship] Timed out waiting for tx hash", + new Error(`No txHash within ${STATUS_POLL_TIMEOUT_MS}ms`), + { + service: "turnkey", + send_transaction_status_id: sendTransactionStatusId, + } + ); + return null; +} diff --git a/lib/web3/turnkey-sponsorship-config.ts b/lib/web3/turnkey-sponsorship-config.ts new file mode 100644 index 000000000..71155818d --- /dev/null +++ b/lib/web3/turnkey-sponsorship-config.ts @@ -0,0 +1,35 @@ +import "server-only"; +import { SPONSORSHIP_CHAIN_IDS } from "./sponsorship-chains-meta"; + +/** + * Chain IDs where gas sponsorship via Turnkey's native Transaction Management + * (Gas Station) is supported. Turnkey signs AND sponsors the underlying + * EVM transaction in a single API call -- there is no ERC-4337 bundler or + * paymaster involved. + * + * Derived from the shared sponsorship chain metadata so the runtime allowlist + * and the billing UI cannot drift. Turnkey covers four mainnets (Ethereum, + * Base, Polygon, Arbitrum) and their canonical testnets; Optimism and BNB are + * absent from the Gas Station. The SDK v5.2.0 CAIP-2 enum lags Turnkey's + * actual coverage and does not yet list `eip155:42161`, so `toCaip2` widens + * the type at the call site in `turnkey-sponsored-tx.ts` until the SDK + * regenerates. + */ +export const SUPPORTED_SPONSORSHIP_CHAINS: ReadonlySet = + SPONSORSHIP_CHAIN_IDS; + +export function isSponsorshipSupported(chainId: number): boolean { + return SUPPORTED_SPONSORSHIP_CHAINS.has(chainId); +} + +/** + * Map an EVM chain ID to the CAIP-2 identifier Turnkey expects on + * `ethSendTransaction.parameters.caip2`. Returns null for unsupported + * chains so callers can fall back to direct signing. + */ +export function toCaip2(chainId: number): string | null { + if (!isSponsorshipSupported(chainId)) { + return null; + } + return `eip155:${chainId}`; +} diff --git a/lib/web3/turnkey-viem-account.ts b/lib/web3/turnkey-viem-account.ts deleted file mode 100644 index 4dc1aba34..000000000 --- a/lib/web3/turnkey-viem-account.ts +++ /dev/null @@ -1,167 +0,0 @@ -import "server-only"; -import type { - Address, - AuthorizationRequest, - Hex, - LocalAccount, - SignableMessage, - SignedAuthorization, -} from "viem"; -import { - hashAuthorization, - hashMessage, - hashTypedData, - serializeTransaction, -} from "viem/utils"; -import { toChecksumAddress } from "@/lib/address-utils"; -import type { OrganizationWallet } from "@/lib/db/schema"; -import { getTurnkeySignerConfig } from "@/lib/turnkey/turnkey-client"; -import { getOrganizationWallet } from "@/lib/web3/wallet-helpers"; - -function parseSignature( - r: string, - s: string, - v: string -): { - r: Hex; - s: Hex; - v: bigint; -} { - const rHex = (r.startsWith("0x") ? r : `0x${r}`) as Hex; - const sHex = (s.startsWith("0x") ? s : `0x${s}`) as Hex; - const vBigInt = BigInt(`0x${v}`); - const normalizedV = vBigInt < BigInt(27) ? vBigInt + BigInt(27) : vBigInt; - return { r: rHex, s: sHex, v: normalizedV }; -} - -async function signHashWithTurnkey( - client: ReturnType["client"], - signWith: string, - organizationId: string, - hash: Hex -): Promise<{ r: Hex; s: Hex; v: bigint }> { - const cleanHash = hash.startsWith("0x") ? hash.slice(2) : hash; - const result = await client.signRawPayload({ - organizationId, - signWith, - payload: cleanHash, - encoding: "PAYLOAD_ENCODING_HEXADECIMAL", - hashFunction: "HASH_FUNCTION_NO_OP", - }); - - return parseSignature(result.r, result.s, result.v); -} - -function combineSignature(sig: { r: Hex; s: Hex; v: bigint }): Hex { - const r = sig.r.startsWith("0x") ? sig.r.slice(2) : sig.r; - const s = sig.s.startsWith("0x") ? sig.s.slice(2) : sig.s; - const v = sig.v.toString(16).padStart(2, "0"); - return `0x${r}${s}${v}` as Hex; -} - -/** - * Creates a viem LocalAccount backed by Turnkey signing. - * - * Used as the `owner` for permissionless.js smart account clients to enable - * EIP-7702 authorization signing and ERC-4337 user operation signing. - */ -export async function createTurnkeyViemAccount( - organizationId: string -): Promise<{ account: LocalAccount; walletRecord: OrganizationWallet }> { - const walletRecord = await getOrganizationWallet(organizationId); - - if (!walletRecord.turnkeySubOrgId) { - throw new Error("Wallet missing Turnkey sub-organization ID"); - } - - const config = getTurnkeySignerConfig( - walletRecord.turnkeySubOrgId, - toChecksumAddress(walletRecord.walletAddress) - ); - - const address = walletRecord.walletAddress as Address; - - const account: LocalAccount = { - address, - publicKey: "0x" as Hex, - source: "turnkey" as string, - type: "local", - - async sign({ hash }: { hash: Hex }): Promise { - const sig = await signHashWithTurnkey( - config.client, - config.signWith, - config.organizationId, - hash - ); - return combineSignature(sig); - }, - - async signMessage({ message }: { message: SignableMessage }): Promise { - const hash = hashMessage(message); - const sig = await signHashWithTurnkey( - config.client, - config.signWith, - config.organizationId, - hash - ); - return combineSignature(sig); - }, - - async signTransaction(transaction, _options?): Promise { - const serialized = serializeTransaction(transaction); - const cleanPayload = serialized.startsWith("0x") - ? serialized.slice(2) - : serialized; - const result = await config.client.signRawPayload({ - organizationId: config.organizationId, - signWith: config.signWith, - payload: cleanPayload, - encoding: "PAYLOAD_ENCODING_HEXADECIMAL", - hashFunction: "HASH_FUNCTION_KECCAK256", - }); - const { r, s, v } = parseSignature(result.r, result.s, result.v); - const yParity = v === BigInt(28) ? 1 : 0; - return serializeTransaction(transaction, { r, s, yParity }); - }, - - // biome-ignore lint/suspicious/noExplicitAny: viem TypedDataDefinition generic is complex - async signTypedData(parameters: any): Promise { - const hash = hashTypedData(parameters); - const sig = await signHashWithTurnkey( - config.client, - config.signWith, - config.organizationId, - hash - ); - return combineSignature(sig); - }, - - async signAuthorization( - authorization: AuthorizationRequest - ): Promise { - const hash = hashAuthorization(authorization); - const { r, s, v } = await signHashWithTurnkey( - config.client, - config.signWith, - config.organizationId, - hash - ); - const yParity = v === BigInt(28) ? 1 : 0; - const contractAddress = - "address" in authorization - ? authorization.address - : authorization.contractAddress; - return { - address: contractAddress, - chainId: authorization.chainId ?? 0, - nonce: authorization.nonce ?? 0, - r, - s, - yParity, - } as SignedAuthorization; - }, - }; - - return { account, walletRecord }; -} diff --git a/plugins/web3/steps/approve-token-core.ts b/plugins/web3/steps/approve-token-core.ts index e7bb84820..eae5b0984 100644 --- a/plugins/web3/steps/approve-token-core.ts +++ b/plugins/web3/steps/approve-token-core.ts @@ -26,7 +26,7 @@ import { executeContractCallAsRole, executeContractCallAsSafe, } from "@/lib/safe/execute-as-safe"; -import { resolveSignerForNode } from "@/lib/safe/signer-resolver"; +import { resolveSignerForNode, SIGNER_MODE } from "@/lib/safe/signer-resolver"; import { getChainAdapter } from "@/lib/web3/chain-adapter"; import { classifyRevert, @@ -34,10 +34,11 @@ import { type RevertKind, } from "@/lib/web3/decode-revert-error"; import { resolveGasLimitOverrides } from "@/lib/web3/gas-defaults"; -import { isSponsorshipSupported } from "@/lib/web3/pimlico-config"; +import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config"; import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; import { executeSponsoredContractTransaction } from "@/lib/web3/sponsored-transaction-manager"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; +import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -52,7 +53,7 @@ export type ApproveTokenCoreInput = { gasLimitMultiplier?: string; tokenAddress?: string; // KEEP-137: Route through private mempool (Flashbots Protect). Skips - // ERC-4337 sponsorship -- mutually exclusive. + // Turnkey-sponsored execution -- mutually exclusive. usePrivateMempool?: boolean; // Strict mode: when true and usePrivateMempool is true, failing to reach the // private RPC does NOT fall back to the public mempool. Ignored otherwise. @@ -249,15 +250,15 @@ export async function approveTokenCore( rpcManager, }; - // Try gas-sponsored execution first (ERC-4337 via Pimlico). + // Try gas-sponsored execution first via Turnkey Gas Station (KEEP-464). // KEEP-137: skip sponsorship when routing through a private mempool -- - // ERC-4337 bundlers use their own RPC (Pimlico), which bypasses Flashbots Protect. - // KEEP-177: skip sponsorship in Safe mode -- the 4337 bundler sends from - // its own smart account, which would change msg.sender away from the Safe. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. + // Also skip in Safe mode: the sponsored path sends from the org's EOA wallet, + // which would change msg.sender away from the Safe. if ( isSponsorshipSupported(chainId) && !usePrivateMempool && - signerMode.kind === "eoa" && + signerMode.kind === SIGNER_MODE.EOA && isGasSponsorshipEnabled() ) { try { @@ -325,6 +326,25 @@ export async function approveTokenCore( } ); } catch (error) { + if (isSponsoredTxRevertError(error)) { + logUserError( + ErrorCategory.TRANSACTION, + "[Approve Token] Sponsored transaction reverted on-chain", + error, + { + plugin_name: "web3", + action_name: "approve-token", + chain_id: String(chainId), + tx_hash: error.txHash, + send_transaction_status_id: error.sendTransactionStatusId, + revert_chain_depth: String(error.revertChain.length), + } + ); + return { + success: false, + error: `Transaction reverted: ${error.message}`, + }; + } logUserError( ErrorCategory.TRANSACTION, "[Approve Token] Sponsorship attempted but failed, falling back to direct signing", @@ -389,7 +409,7 @@ export async function approveTokenCore( } let receipt: Awaited>; - if (signerMode.kind === "safe-role") { + if (signerMode.kind === SIGNER_MODE.SAFE_ROLE) { receipt = await executeContractCallAsRole( signer, { @@ -409,7 +429,7 @@ export async function approveTokenCore( rpcManager, } ); - } else if (signerMode.kind === "safe") { + } else if (signerMode.kind === SIGNER_MODE.SAFE) { receipt = await executeContractCallAsSafe( signer, { diff --git a/plugins/web3/steps/transfer-funds-core.ts b/plugins/web3/steps/transfer-funds-core.ts index 2f6ca5f48..56ef90a71 100644 --- a/plugins/web3/steps/transfer-funds-core.ts +++ b/plugins/web3/steps/transfer-funds-core.ts @@ -25,7 +25,7 @@ import { executeNativeTransferAsRole, executeNativeTransferAsSafe, } from "@/lib/safe/execute-as-safe"; -import { resolveSignerForNode } from "@/lib/safe/signer-resolver"; +import { resolveSignerForNode, SIGNER_MODE } from "@/lib/safe/signer-resolver"; import { getChainAdapter } from "@/lib/web3/chain-adapter"; import { classifyRevert, @@ -36,6 +36,7 @@ import { resolveGasLimitOverrides } from "@/lib/web3/gas-defaults"; import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; import { executeSponsoredTransaction } from "@/lib/web3/sponsored-transaction-manager"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; +import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -47,7 +48,7 @@ export type TransferFundsCoreInput = { recipientAddress: string; gasLimitMultiplier?: string; // KEEP-137: Route through private mempool (Flashbots Protect). Skips - // ERC-4337 sponsorship -- mutually exclusive. + // Turnkey-sponsored execution -- mutually exclusive. usePrivateMempool?: boolean; // Strict mode: when true and usePrivateMempool is true, failing to reach the // private RPC does NOT fall back to the public mempool. Ignored otherwise. @@ -214,15 +215,15 @@ export async function transferFundsCore( }; // KEEP-137: skip sponsorship when routing through a private mempool -- - // ERC-4337 bundlers use their own RPC (Pimlico), which bypasses Flashbots Protect. - // KEEP-177: skip sponsorship in Safe mode -- the 4337 bundler sends from - // its own smart account, which would change msg.sender away from the Safe. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. + // Also skip in Safe mode: the sponsored path sends from the org's EOA wallet, + // which would change msg.sender away from the Safe. if ( !usePrivateMempool && - signerMode.kind === "eoa" && + signerMode.kind === SIGNER_MODE.EOA && isGasSponsorshipEnabled() ) { - // Try gas-sponsored execution first (ERC-4337 via Pimlico) + // Try gas-sponsored execution first via Turnkey Gas Station (KEEP-464) try { const sponsoredResult = await executeSponsoredTransaction({ organizationId, @@ -263,6 +264,28 @@ export async function transferFundsCore( } ); } catch (error) { + if (isSponsoredTxRevertError(error)) { + // Sponsored tx was broadcast and reverted on-chain. Do NOT fall back + // to direct signing -- the underlying call would just revert again + // and the user would pay gas twice. + logUserError( + ErrorCategory.TRANSACTION, + "[Transfer Funds] Sponsored transaction reverted on-chain", + error, + { + plugin_name: "web3", + action_name: "transfer-funds", + chain_id: String(chainId), + tx_hash: error.txHash, + send_transaction_status_id: error.sendTransactionStatusId, + revert_chain_depth: String(error.revertChain.length), + } + ); + return { + success: false, + error: `Transaction reverted: ${error.message}`, + }; + } logUserError( ErrorCategory.TRANSACTION, "[Transfer Funds] Sponsorship attempted but failed, falling back to direct signing", @@ -298,7 +321,7 @@ export async function transferFundsCore( // RPC failure here surfaces as a step error to stay consistent with // transfer-token-core's pattern. See review #923-r3 (MEDIUM). const fundingHolderAddress: string = - signerMode.kind === "safe-role" || signerMode.kind === "safe" + signerMode.kind === SIGNER_MODE.SAFE_ROLE || signerMode.kind === SIGNER_MODE.SAFE ? signerMode.safeAddress : walletAddress; const nativeBalance = await rpcManager.executeWithFailover( @@ -326,7 +349,7 @@ export async function transferFundsCore( try { let receipt: Awaited>; - if (signerMode.kind === "safe-role") { + if (signerMode.kind === SIGNER_MODE.SAFE_ROLE) { receipt = await executeNativeTransferAsRole( signer, { @@ -344,7 +367,7 @@ export async function transferFundsCore( rpcManager, } ); - } else if (signerMode.kind === "safe") { + } else if (signerMode.kind === SIGNER_MODE.SAFE) { receipt = await executeNativeTransferAsSafe( signer, { diff --git a/plugins/web3/steps/transfer-token-core.ts b/plugins/web3/steps/transfer-token-core.ts index 140a7f0f7..446271c4c 100644 --- a/plugins/web3/steps/transfer-token-core.ts +++ b/plugins/web3/steps/transfer-token-core.ts @@ -30,7 +30,7 @@ import { executeContractCallAsRole, executeContractCallAsSafe, } from "@/lib/safe/execute-as-safe"; -import { resolveSignerForNode } from "@/lib/safe/signer-resolver"; +import { resolveSignerForNode, SIGNER_MODE } from "@/lib/safe/signer-resolver"; import { getChainAdapter } from "@/lib/web3/chain-adapter"; import { classifyRevert, @@ -38,10 +38,11 @@ import { type RevertKind, } from "@/lib/web3/decode-revert-error"; import { resolveGasLimitOverrides } from "@/lib/web3/gas-defaults"; -import { isSponsorshipSupported } from "@/lib/web3/pimlico-config"; +import { isSponsorshipSupported } from "@/lib/web3/turnkey-sponsorship-config"; import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; import { executeSponsoredContractTransaction } from "@/lib/web3/sponsored-transaction-manager"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; +import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -55,7 +56,7 @@ export type TransferTokenCoreInput = { gasLimitMultiplier?: string; tokenAddress?: string; // KEEP-137: Route through private mempool (Flashbots Protect). Skips - // ERC-4337 sponsorship -- mutually exclusive. + // Turnkey-sponsored execution -- mutually exclusive. usePrivateMempool?: boolean; // Strict mode: when true and usePrivateMempool is true, failing to reach the // private RPC does NOT fall back to the public mempool. Ignored otherwise. @@ -348,15 +349,15 @@ export async function transferTokenCore( rpcManager, }; - // Try gas-sponsored execution first (ERC-4337 via Pimlico). + // Try gas-sponsored execution first via Turnkey Gas Station (KEEP-464). // KEEP-137: skip sponsorship when routing through a private mempool -- - // ERC-4337 bundlers use their own RPC (Pimlico), which bypasses Flashbots Protect. - // KEEP-177: skip sponsorship in Safe mode -- the 4337 bundler sends from - // its own smart account, which would change msg.sender away from the Safe. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. + // Also skip in Safe mode: the sponsored path sends from the org's EOA wallet, + // which would change msg.sender away from the Safe. if ( isSponsorshipSupported(chainId) && !usePrivateMempool && - signerMode.kind === "eoa" && + signerMode.kind === SIGNER_MODE.EOA && isGasSponsorshipEnabled() ) { try { @@ -415,6 +416,25 @@ export async function transferTokenCore( } ); } catch (error) { + if (isSponsoredTxRevertError(error)) { + logUserError( + ErrorCategory.TRANSACTION, + "[Transfer Token] Sponsored transaction reverted on-chain", + error, + { + plugin_name: "web3", + action_name: "transfer-token", + chain_id: String(chainId), + tx_hash: error.txHash, + send_transaction_status_id: error.sendTransactionStatusId, + revert_chain_depth: String(error.revertChain.length), + } + ); + return { + success: false, + error: `Transaction reverted: ${error.message}`, + }; + } logUserError( ErrorCategory.TRANSACTION, "[Transfer Token] Sponsorship attempted but failed, falling back to direct signing", @@ -450,7 +470,7 @@ export async function transferTokenCore( try { const tokenHolderAddress = - signerMode.kind === "safe-role" || signerMode.kind === "safe" + signerMode.kind === SIGNER_MODE.SAFE_ROLE || signerMode.kind === SIGNER_MODE.SAFE ? signerMode.safeAddress : signerAddress; @@ -487,7 +507,7 @@ export async function transferTokenCore( } let receipt: Awaited>; - if (signerMode.kind === "safe-role") { + if (signerMode.kind === SIGNER_MODE.SAFE_ROLE) { receipt = await executeContractCallAsRole( signer, { @@ -507,7 +527,7 @@ export async function transferTokenCore( rpcManager, } ); - } else if (signerMode.kind === "safe") { + } else if (signerMode.kind === SIGNER_MODE.SAFE) { receipt = await executeContractCallAsSafe( signer, { diff --git a/plugins/web3/steps/write-contract-core.ts b/plugins/web3/steps/write-contract-core.ts index ec4689118..0a3e45f98 100644 --- a/plugins/web3/steps/write-contract-core.ts +++ b/plugins/web3/steps/write-contract-core.ts @@ -29,7 +29,7 @@ import { executeContractCallAsRole, executeContractCallAsSafe, } from "@/lib/safe/execute-as-safe"; -import { resolveSignerForNode } from "@/lib/safe/signer-resolver"; +import { resolveSignerForNode, SIGNER_MODE } from "@/lib/safe/signer-resolver"; import { getChainAdapter } from "@/lib/web3/chain-adapter"; import { classifyRevert, @@ -43,6 +43,7 @@ import { import { resolveOrganizationContext } from "@/lib/web3/resolve-org-context"; import { executeSponsoredContractTransaction } from "@/lib/web3/sponsored-transaction-manager"; import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; +import { isSponsoredTxRevertError } from "@/lib/web3/turnkey-revert"; import { type TransactionContext, withNonceSession, @@ -62,7 +63,7 @@ export type WriteContractCoreInput = { // Galileo demands >= 2 gwei but the strategy floor is lower). priorityFeeGwei?: string; // KEEP-137: Route the write transaction through the chain's private mempool - // RPC (e.g. Flashbots Protect). Skips ERC-4337 sponsorship -- mutually exclusive. + // RPC (e.g. Flashbots Protect). Skips Turnkey-sponsored execution -- mutually exclusive. usePrivateMempool?: boolean; // When true and usePrivateMempool is true, failing to reach the private RPC // does NOT fall back to the public mempool. Ignored when usePrivateMempool is false. @@ -328,14 +329,14 @@ export async function writeContractCore( rpcManager, }; - // Try gas-sponsored execution first (ERC-4337 via Pimlico). + // Try gas-sponsored execution first via Turnkey Gas Station (KEEP-464). // KEEP-137: skip sponsorship when routing through a private mempool -- - // ERC-4337 bundlers use their own RPC (Pimlico), which bypasses Flashbots Protect. - // KEEP-177: skip sponsorship in Safe mode -- the 4337 bundler sends from - // its own smart account, which would change msg.sender away from the Safe. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. + // Also skip in Safe mode: the sponsored path sends from the org's EOA wallet, + // which would change msg.sender away from the Safe. if ( !usePrivateMempool && - signerMode.kind === "eoa" && + signerMode.kind === SIGNER_MODE.EOA && isGasSponsorshipEnabled() ) { try { @@ -382,6 +383,25 @@ export async function writeContractCore( } ); } catch (error) { + if (isSponsoredTxRevertError(error)) { + logUserError( + ErrorCategory.TRANSACTION, + "[Write Contract] Sponsored transaction reverted on-chain", + error, + { + plugin_name: "web3", + action_name: "write-contract", + chain_id: String(chainId), + tx_hash: error.txHash, + send_transaction_status_id: error.sendTransactionStatusId, + revert_chain_depth: String(error.revertChain.length), + } + ); + return { + success: false, + error: `Transaction reverted: ${error.message}`, + }; + } logUserError( ErrorCategory.TRANSACTION, "[Write Contract] Sponsorship attempted but failed, falling back to direct signing", @@ -420,7 +440,7 @@ export async function writeContractCore( try { let receipt: Awaited>; - if (signerMode.kind === "safe-role") { + if (signerMode.kind === SIGNER_MODE.SAFE_ROLE) { receipt = await executeContractCallAsRole( signer, { @@ -441,7 +461,7 @@ export async function writeContractCore( rpcManager, } ); - } else if (signerMode.kind === "safe") { + } else if (signerMode.kind === SIGNER_MODE.SAFE) { receipt = await executeContractCallAsSafe( signer, { diff --git a/tests/unit/approve-token.test.ts b/tests/unit/approve-token.test.ts index 76be6bab6..d188599ee 100644 --- a/tests/unit/approve-token.test.ts +++ b/tests/unit/approve-token.test.ts @@ -161,11 +161,12 @@ vi.mock("@/lib/web3/transaction-manager", () => ({ })); // Mock sponsorship (disabled by default so tests exercise the direct signing path) -vi.mock("@/lib/web3/pimlico-config", () => ({ +vi.mock("@/lib/web3/turnkey-sponsorship-config", () => ({ isSponsorshipSupported: () => false, })); vi.mock("@/lib/safe/signer-resolver", () => ({ + SIGNER_MODE: { EOA: "eoa", SAFE: "safe", SAFE_ROLE: "safe-role" }, resolveSignerMode: vi.fn().mockResolvedValue({ kind: "eoa", ownerAddress: "0xwalletaddress", diff --git a/tests/unit/sponsored-client.test.ts b/tests/unit/sponsored-client.test.ts index 85fdaef5b..3e3ffe761 100644 --- a/tests/unit/sponsored-client.test.ts +++ b/tests/unit/sponsored-client.test.ts @@ -1,158 +1,94 @@ -import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -// ── Mocks (before imports) ─────────────────────────────────────────── +// Mocks must be declared before importing the file under test. vi.mock("server-only", () => ({})); -// Capture what createSmartAccountClient is called with so we can pull the -// estimateFeesPerGas callback back out and exercise it directly. Returns -// an opaque client - not exercised in these tests. -const mockCreateSmartAccountClient: Mock = vi.fn((_args: unknown) => ({})); -vi.mock("permissionless", () => ({ - createSmartAccountClient: (...args: unknown[]) => - mockCreateSmartAccountClient(...args), -})); - -// Pimlico client returns whatever the test has currently set on the spy. -const mockGetUserOperationGasPrice = vi.fn(); -vi.mock("permissionless/clients/pimlico", () => ({ - createPimlicoClient: () => ({ - getUserOperationGasPrice: (...args: unknown[]) => - mockGetUserOperationGasPrice(...args), - }), -})); +const mockLimit = vi.fn(); +const mockWhere = vi.fn(() => ({ limit: mockLimit })); +const mockFrom = vi.fn(() => ({ where: mockWhere })); +const mockSelect = vi.fn(() => ({ from: mockFrom })); -// Stubs for everything else createSponsoredClient touches before reaching -// the createSmartAccountClient call. -vi.mock("permissionless/accounts", () => ({ - to7702SimpleSmartAccount: vi.fn().mockResolvedValue({ - isDeployed: vi.fn().mockResolvedValue(true), - }), -})); -vi.mock("viem", () => ({ - createPublicClient: vi.fn().mockReturnValue({}), - defineChain: vi.fn().mockReturnValue({}), - http: vi.fn().mockReturnValue({}), -})); -vi.mock("viem/account-abstraction", () => ({ entryPoint08Address: "0x0" })); -vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn() })); vi.mock("@/lib/db", () => ({ db: { - query: { - chains: { - findFirst: vi - .fn() - .mockResolvedValue({ name: "Sepolia", symbol: "ETH" }), - }, - }, + select: () => mockSelect(), }, })); -vi.mock("@/lib/db/schema", () => ({ chains: { chainId: "chainId" } })); -vi.mock("drizzle-orm", () => ({ eq: vi.fn() })); -vi.mock("@/lib/logging", () => ({ - ErrorCategory: { TRANSACTION: "transaction" }, - logSystemError: vi.fn(), -})); -vi.mock("@/lib/metrics", () => ({ - getMetricsCollector: () => ({ - recordError: vi.fn(), - incrementCounter: vi.fn(), - }), -})); -vi.mock("@/lib/metrics/types", () => ({ - LabelKeys: {}, - MetricNames: {}, -})); -vi.mock("@/lib/web3/turnkey-viem-account", () => ({ - createTurnkeyViemAccount: vi.fn().mockResolvedValue({ - account: { address: "0xabc" }, - walletRecord: { walletAddress: "0xabc" }, - }), -})); -vi.mock("@/lib/web3/eip7702-delegation", () => ({ - recordDelegationIfNeeded: vi.fn().mockResolvedValue(undefined), + +vi.mock("@/lib/db/schema-extensions", () => ({ + organizationWallets: { + walletAddress: "walletAddress", + turnkeySubOrgId: "turnkeySubOrgId", + organizationId: "organizationId", + isActive: "isActive", + }, })); -vi.mock("@/lib/web3/pimlico-config", () => ({ - getPimlicoUrl: vi.fn().mockReturnValue("https://pimlico.test"), - isSponsorshipSupported: vi.fn().mockReturnValue(true), + +vi.mock("drizzle-orm", () => ({ + and: vi.fn(), + eq: vi.fn(), })); -// ── Import under test ──────────────────────────────────────────────── +const mockIsSponsorshipSupported = vi.fn(); +vi.mock("@/lib/web3/turnkey-sponsorship-config", () => ({ + isSponsorshipSupported: (...args: unknown[]) => + mockIsSponsorshipSupported(...args), +})); import { createSponsoredClient } from "@/lib/web3/sponsored-client"; -// ── Helpers ────────────────────────────────────────────────────────── - -type EstimateFeesPerGas = () => Promise<{ - maxFeePerGas: bigint; - maxPriorityFeePerGas: bigint; -}>; - -type CapturedClientArgs = { - userOperation?: { estimateFeesPerGas?: EstimateFeesPerGas }; -}; - -async function buildSponsoredClient(): Promise<{ - result: unknown; - args: CapturedClientArgs | undefined; -}> { - const result = await createSponsoredClient( - "org-id", - 11_155_111, - "https://sepolia.test" - ); - const args = (mockCreateSmartAccountClient as Mock).mock.calls[0]?.[0] as - | CapturedClientArgs - | undefined; - return { result, args }; -} - -// ── Tests ──────────────────────────────────────────────────────────── - beforeEach(() => { vi.clearAllMocks(); }); -describe("createSponsoredClient estimateFeesPerGas wiring", () => { - // KEEP-394: this test guarantees the clamp helper is actually invoked - // before the fee pair reaches the smart account client. If someone - // removes the clampSponsoredFees() call and passes gasPrices.fast - // through directly, this fails. - it("clamps Pimlico's invalid fee pair before returning to the bundler", async () => { - mockGetUserOperationGasPrice.mockResolvedValue({ - fast: { - maxFeePerGas: BigInt(2_463_760), - maxPriorityFeePerGas: BigInt(100_000_000), - }, - }); +describe("createSponsoredClient", () => { + it("returns null when the chain is not in the Turnkey allowlist", async () => { + mockIsSponsorshipSupported.mockReturnValue(false); + + const result = await createSponsoredClient("org_1", 10); + + expect(result).toBeNull(); + expect(mockSelect).not.toHaveBeenCalled(); + }); - const { result, args } = await buildSponsoredClient(); - expect(result).not.toBeNull(); - const estimateFeesPerGas = args?.userOperation?.estimateFeesPerGas; - expect(estimateFeesPerGas).toBeTypeOf("function"); + it("returns null when the org has no active wallet", async () => { + mockIsSponsorshipSupported.mockReturnValue(true); + mockLimit.mockResolvedValue([]); - const fees = await (estimateFeesPerGas as EstimateFeesPerGas)(); + const result = await createSponsoredClient("org_1", 1); - expect(fees.maxFeePerGas).toBeGreaterThanOrEqual(fees.maxPriorityFeePerGas); - expect(fees.maxFeePerGas).toBe(BigInt(100_000_000)); - expect(fees.maxPriorityFeePerGas).toBe(BigInt(100_000_000)); + expect(result).toBeNull(); }); - it("passes a valid Pimlico fee pair through unchanged", async () => { - const validPair = { - maxFeePerGas: BigInt(50_000_000_000), - maxPriorityFeePerGas: BigInt(2_000_000_000), - }; - mockGetUserOperationGasPrice.mockResolvedValue({ fast: validPair }); + it("returns null when the wallet row is missing the sub-org id", async () => { + mockIsSponsorshipSupported.mockReturnValue(true); + mockLimit.mockResolvedValue([ + { + walletAddress: "0xabc", + turnkeySubOrgId: null, + }, + ]); + + const result = await createSponsoredClient("org_1", 1); + + expect(result).toBeNull(); + }); - const { result, args } = await buildSponsoredClient(); - expect(result).not.toBeNull(); - const estimateFeesPerGas = args?.userOperation?.estimateFeesPerGas; - expect(estimateFeesPerGas).toBeTypeOf("function"); + it("returns the Turnkey identifiers when the org wallet is fully provisioned", async () => { + mockIsSponsorshipSupported.mockReturnValue(true); + mockLimit.mockResolvedValue([ + { + walletAddress: "0xabc", + turnkeySubOrgId: "suborg-123", + }, + ]); - const fees = await (estimateFeesPerGas as EstimateFeesPerGas)(); + const result = await createSponsoredClient("org_1", 8453); - expect(fees.maxFeePerGas).toBe(BigInt(50_000_000_000)); - expect(fees.maxPriorityFeePerGas).toBe(BigInt(2_000_000_000)); + expect(result).toEqual({ + subOrgId: "suborg-123", + walletAddress: "0xabc", + chainId: 8453, + }); }); }); diff --git a/tests/unit/sponsored-fee-clamp.test.ts b/tests/unit/sponsored-fee-clamp.test.ts deleted file mode 100644 index 88dfa1a69..000000000 --- a/tests/unit/sponsored-fee-clamp.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { clampSponsoredFees } from "@/lib/web3/sponsored-fee-clamp"; - -describe("clampSponsoredFees", () => { - // KEEP-394: Pimlico's getUserOperationGasPrice has been observed - // returning an invalid pair on Sepolia. Mirror the KEEP-384 regression - // test for the direct-signing path. - it("lifts maxFeePerGas to maxPriorityFeePerGas when given an invalid pair", () => { - // Wei values from a reported Sepolia incident (chainId 11155111). - const fees = { - maxFeePerGas: BigInt(2_463_760), - maxPriorityFeePerGas: BigInt(100_000_000), - }; - - const clamped = clampSponsoredFees(fees); - - expect(clamped.maxFeePerGas).toBeGreaterThanOrEqual( - clamped.maxPriorityFeePerGas - ); - expect(clamped.maxPriorityFeePerGas).toBe(BigInt(100_000_000)); - expect(clamped.maxFeePerGas).toBe(BigInt(100_000_000)); - }); - - it("preserves a valid pair unchanged", () => { - const fees = { - maxFeePerGas: BigInt(50_000_000_000), - maxPriorityFeePerGas: BigInt(2_000_000_000), - }; - - const clamped = clampSponsoredFees(fees); - - expect(clamped).toBe(fees); - }); - - it("preserves an exactly equal pair unchanged", () => { - const fees = { - maxFeePerGas: BigInt(1_000_000_000), - maxPriorityFeePerGas: BigInt(1_000_000_000), - }; - - const clamped = clampSponsoredFees(fees); - - expect(clamped.maxFeePerGas).toBe(clamped.maxPriorityFeePerGas); - expect(clamped).toBe(fees); - }); - - it("returns a valid pair when both values are zero", () => { - const fees = { - maxFeePerGas: BigInt(0), - maxPriorityFeePerGas: BigInt(0), - }; - - const clamped = clampSponsoredFees(fees); - - expect(clamped.maxFeePerGas).toBe(BigInt(0)); - expect(clamped.maxPriorityFeePerGas).toBe(BigInt(0)); - }); - - it("handles the boundary case where maxFee is one wei below priority", () => { - const fees = { - maxFeePerGas: BigInt(99_999_999), - maxPriorityFeePerGas: BigInt(100_000_000), - }; - - const clamped = clampSponsoredFees(fees); - - expect(clamped.maxFeePerGas).toBe(BigInt(100_000_000)); - expect(clamped.maxPriorityFeePerGas).toBe(BigInt(100_000_000)); - }); - - it("preserves the boundary case where maxFee is one wei above priority", () => { - const fees = { - maxFeePerGas: BigInt(100_000_001), - maxPriorityFeePerGas: BigInt(100_000_000), - }; - - const clamped = clampSponsoredFees(fees); - - expect(clamped).toBe(fees); - }); -}); diff --git a/tests/unit/sponsored-transaction-manager.test.ts b/tests/unit/sponsored-transaction-manager.test.ts index 7ec83a830..4ad6fd720 100644 --- a/tests/unit/sponsored-transaction-manager.test.ts +++ b/tests/unit/sponsored-transaction-manager.test.ts @@ -24,19 +24,23 @@ vi.mock("@/lib/web3/chainlink-feeds", () => ({ const mockIsSponsorshipSupported = vi.fn(); -vi.mock("@/lib/web3/pimlico-config", () => ({ +vi.mock("@/lib/web3/turnkey-sponsorship-config", () => ({ isSponsorshipSupported: (...args: unknown[]) => mockIsSponsorshipSupported(...args), })); -const mockSendTransaction = vi.fn(); const mockCreateSponsoredClient = vi.fn(); - vi.mock("@/lib/web3/sponsored-client", () => ({ createSponsoredClient: (...args: unknown[]) => mockCreateSponsoredClient(...args), })); +const mockSubmitTurnkeySponsoredTransaction = vi.fn(); +vi.mock("@/lib/web3/turnkey-sponsored-tx", () => ({ + submitTurnkeySponsoredTransaction: (...args: unknown[]) => + mockSubmitTurnkeySponsoredTransaction(...args), +})); + const mockWaitForTransactionReceipt = vi.fn(); vi.mock("viem", () => ({ @@ -100,9 +104,14 @@ function setupSuccessfulSponsorship(): void { remainingCents: 500, }); mockCreateSponsoredClient.mockResolvedValue({ - smartAccountClient: { sendTransaction: mockSendTransaction }, + subOrgId: "suborg-123", + walletAddress: "0xwallet", + chainId: 11_155_111, + }); + mockSubmitTurnkeySponsoredTransaction.mockResolvedValue({ + txHash: "0xtxhash", + sendTransactionStatusId: "status-1", }); - mockSendTransaction.mockResolvedValue("0xtxhash"); mockWaitForTransactionReceipt.mockResolvedValue({ status: "success", gasUsed: BigInt(21_000), @@ -149,7 +158,7 @@ describe("executeSponsoredTransaction", () => { expect(mockCreateSponsoredClient).not.toHaveBeenCalled(); }); - it("returns null when client creation fails", async () => { + it("returns null when client preflight fails (non-Turnkey wallet)", async () => { mockIsSponsorshipSupported.mockReturnValue(true); mockCheckGasCredits.mockResolvedValue({ allowed: true, @@ -159,6 +168,16 @@ describe("executeSponsoredTransaction", () => { const result = await executeSponsoredTransaction(baseTxParams); + expect(result).toBeNull(); + expect(mockSubmitTurnkeySponsoredTransaction).not.toHaveBeenCalled(); + }); + + it("returns null when Turnkey rejects the activity", async () => { + setupSuccessfulSponsorship(); + mockSubmitTurnkeySponsoredTransaction.mockResolvedValue(null); + + const result = await executeSponsoredTransaction(baseTxParams); + expect(result).toBeNull(); }); @@ -198,20 +217,25 @@ describe("executeSponsoredTransaction", () => { ); }); - it("returns null and logs error when sendTransaction throws", async () => { - mockIsSponsorshipSupported.mockReturnValue(true); - mockCheckGasCredits.mockResolvedValue({ - allowed: true, - remainingCents: 500, - }); - mockCreateSponsoredClient.mockResolvedValue({ - smartAccountClient: { sendTransaction: mockSendTransaction }, - }); - mockSendTransaction.mockRejectedValue(new Error("bundler rejected")); + it("forwards value and data to the Turnkey wrapper", async () => { + setupSuccessfulSponsorship(); - const result = await executeSponsoredTransaction(baseTxParams); + await executeSponsoredTransaction({ + ...baseTxParams, + value: BigInt(1234), + data: "0xdead", + }); - expect(result).toBeNull(); + expect(mockSubmitTurnkeySponsoredTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + subOrgId: "suborg-123", + walletAddress: "0xwallet", + chainId: 11_155_111, + to: "0xrecipient", + value: BigInt(1234), + data: "0xdead", + }) + ); }); it("still returns result when recordGasUsage fails", async () => { @@ -301,4 +325,18 @@ describe("executeSponsoredContractTransaction", () => { expect(result?.success).toBe(true); expect(result?.gasUsed).toBe("21000"); }); + + it("encodes call data and forwards it to the Turnkey wrapper", async () => { + setupSuccessfulSponsorship(); + + await executeSponsoredContractTransaction(baseContractParams); + + expect(mockSubmitTurnkeySponsoredTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + subOrgId: "suborg-123", + to: "0xrecipient", + data: "0xencoded", + }) + ); + }); }); diff --git a/tests/unit/turnkey-revert.test.ts b/tests/unit/turnkey-revert.test.ts new file mode 100644 index 000000000..2285818fe --- /dev/null +++ b/tests/unit/turnkey-revert.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { + formatRevertChain, + isSponsoredTxRevertError, + type RevertChainEntry, + SponsoredTxRevertError, +} from "@/lib/web3/turnkey-revert"; + +describe("SponsoredTxRevertError", () => { + it("carries the structured revert details", () => { + const err = new SponsoredTxRevertError({ + message: "execution reverted", + txHash: "0xabc", + sendTransactionStatusId: "status-1", + revertChain: [{ displayMessage: "boom" }], + }); + + expect(err.name).toBe("SponsoredTxRevertError"); + expect(err.txHash).toBe("0xabc"); + expect(err.sendTransactionStatusId).toBe("status-1"); + expect(err.revertChain).toHaveLength(1); + }); +}); + +describe("isSponsoredTxRevertError", () => { + it("recognises the typed error across module boundaries", () => { + const err = new SponsoredTxRevertError({ + message: "x", + txHash: "0xabc", + sendTransactionStatusId: "s", + revertChain: [], + }); + + expect(isSponsoredTxRevertError(err)).toBe(true); + }); + + it("rejects plain Error and unrelated values", () => { + expect(isSponsoredTxRevertError(new Error("nope"))).toBe(false); + expect(isSponsoredTxRevertError(null)).toBe(false); + expect(isSponsoredTxRevertError({ kind: "sponsored-tx-revert" })).toBe( + false + ); + }); +}); + +describe("formatRevertChain", () => { + it("falls back to generic message for empty chain", () => { + expect(formatRevertChain([])).toBe("execution reverted"); + }); + + it("prefers decoded custom error name and params", () => { + const chain: RevertChainEntry[] = [ + { + address: "0x1111111111111111111111111111111111111111", + errorType: "custom", + custom: { + errorName: "InsufficientBalance", + paramsJson: '{"have":1,"need":2}', + }, + }, + ]; + + expect(formatRevertChain(chain)).toBe( + 'InsufficientBalance({"have":1,"need":2}) at 0x1111111111111111111111111111111111111111' + ); + }); + + it("uses the Solidity message for native errors", () => { + const chain: RevertChainEntry[] = [ + { + errorType: "native", + native: { nativeType: "error_string", message: "INVALID_ROUTER" }, + }, + ]; + + expect(formatRevertChain(chain)).toBe("INVALID_ROUTER"); + }); + + it("renders the selector when the contract error is unknown", () => { + const chain: RevertChainEntry[] = [ + { + errorType: "unknown", + unknown: { selector: "0x12345678", data: "0x12345678deadbeef" }, + }, + ]; + + expect(formatRevertChain(chain)).toBe( + "unknown error (selector 0x12345678)" + ); + }); + + it("joins nested reverts outermost-to-innermost", () => { + const chain: RevertChainEntry[] = [ + { + address: "0xaaa", + native: { nativeType: "error_string", message: "Outer failure" }, + }, + { + address: "0xbbb", + custom: { errorName: "InnerError", paramsJson: '{"x":1}' }, + }, + ]; + + expect(formatRevertChain(chain)).toBe( + 'Outer failure at 0xaaa -> InnerError({"x":1}) at 0xbbb' + ); + }); +}); diff --git a/tests/unit/write-contract-core.test.ts b/tests/unit/write-contract-core.test.ts index d443c4065..20b63486c 100644 --- a/tests/unit/write-contract-core.test.ts +++ b/tests/unit/write-contract-core.test.ts @@ -136,6 +136,7 @@ vi.mock("@/lib/web3/wallet-helpers", () => ({ })); vi.mock("@/lib/safe/signer-resolver", () => ({ + SIGNER_MODE: { EOA: "eoa", SAFE: "safe", SAFE_ROLE: "safe-role" }, resolveSignerMode: vi.fn().mockResolvedValue({ kind: "eoa", ownerAddress: "0xwalletaddress",