From 39b68c9b115d2d5072612025e14d52b4041cf643 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Mon, 11 May 2026 19:39:39 -0300 Subject: [PATCH 1/9] refactor(web3): replace Pimlico sponsorship with Turnkey Gas Station (KEEP-464) Switch the gas sponsorship pipeline from ERC-4337 + Pimlico to Turnkey's native Transaction Management (Gas Station). Turnkey signs and sponsors the underlying EVM transaction in a single ethSendTransaction activity, so the whole UserOperation pipeline goes away on the sponsored path. Adds turnkey-sponsorship-config (chain allowlist: ETH, Base, Polygon mainnet + their testnets, Arbitrum probed at runtime; drops Optimism) and turnkey-sponsored-tx (wraps ethSendTransaction + polls getSendTransactionStatus for the broadcast txHash). sponsored-client becomes a thin Turnkey-wallet preflight; sponsored-transaction-manager calls the wrapper and continues to meter on-chain gasUsed against gas-credit balances. Deletes pimlico-config, sponsored-fee-clamp (Turnkey fills gas itself, no KEEP-394 fee-inversion clamp needed), eip7702-delegation (no inline 4337 delegation), and the Para viem-account adapter (the sponsored path was its only remaining caller; Para wallets fall through to direct signing via the existing wallet-helpers guard). --- lib/para/viem-account-adapter.ts | 167 --------------------- lib/web3/eip7702-delegation.ts | 105 ------------- lib/web3/pimlico-config.ts | 49 ------ lib/web3/sponsored-client.ts | 168 ++++++--------------- lib/web3/sponsored-fee-clamp.ts | 21 --- lib/web3/sponsored-transaction-manager.ts | 116 +++++++-------- lib/web3/turnkey-sponsored-tx.ts | 174 ++++++++++++++++++++++ lib/web3/turnkey-sponsorship-config.ts | 42 ++++++ 8 files changed, 318 insertions(+), 524 deletions(-) delete mode 100644 lib/para/viem-account-adapter.ts delete mode 100644 lib/web3/eip7702-delegation.ts delete mode 100644 lib/web3/pimlico-config.ts delete mode 100644 lib/web3/sponsored-fee-clamp.ts create mode 100644 lib/web3/turnkey-sponsored-tx.ts create mode 100644 lib/web3/turnkey-sponsorship-config.ts diff --git a/lib/para/viem-account-adapter.ts b/lib/para/viem-account-adapter.ts deleted file mode 100644 index f214f9eff..000000000 --- a/lib/para/viem-account-adapter.ts +++ /dev/null @@ -1,167 +0,0 @@ -import "server-only"; -import { Environment, Para as ParaServer } from "@getpara/server-sdk"; -import type { - Address, - AuthorizationRequest, - Hex, - LocalAccount, - SignableMessage, - SignedAuthorization, -} from "viem"; -import { - hashAuthorization, - hashMessage, - hashTypedData, - serializeTransaction, -} from "viem/utils"; -import { decryptUserShare } from "@/lib/encryption"; -import { getOrganizationWallet } from "@/lib/para/wallet-helpers"; - -type ParaWalletRecord = { - userId: string; - paraWalletId: string | null; - walletAddress: string; - userShare: string | null; -}; - -function hexToBase64(hex: string): string { - const clean = hex.startsWith("0x") ? hex.slice(2) : hex; - return Buffer.from(clean, "hex").toString("base64"); -} - -function parseSignature(sigHex: string): { r: Hex; s: Hex; v: bigint } { - const clean = sigHex.startsWith("0x") ? sigHex.slice(2) : sigHex; - const r = `0x${clean.slice(0, 64)}` as Hex; - const s = `0x${clean.slice(64, 128)}` as Hex; - const vByte = Number.parseInt(clean.slice(128, 130), 16); - const v = BigInt(vByte < 27 ? vByte + 27 : vByte); - return { r, s, v }; -} - -function initializeParaClient(): ParaServer { - const apiKey = process.env.PARA_API_KEY; - if (!apiKey) { - throw new Error("PARA_API_KEY not configured"); - } - const env = process.env.PARA_ENVIRONMENT || "beta"; - const disableWebSockets = process.env.PARA_DISABLE_WEBSOCKETS === "true"; - return new ParaServer( - env === "prod" ? Environment.PROD : Environment.BETA, - apiKey, - { disableWebSockets } - ); -} - -/** - * Creates a viem LocalAccount backed by Para's MPC signing. - * - * The returned account delegates signMessage/signTypedData/signTransaction - * to Para's server-side MPC protocol via user shares. This allows it to - * be used as the `owner` for permissionless.js smart account clients. - * - * Para's signMessage signs raw bytes directly -- the EIP-191/712 prefixes - * are added by viem's hashMessage/hashTypedData at the adapter layer. - * This means account.sign({ hash }) works for EIP-7702 authorization - * signing via hashAuthorization() + sign(). - */ -export async function createParaViemAccount( - organizationId: string -): Promise<{ account: LocalAccount; walletRecord: ParaWalletRecord }> { - const walletRecord = await getOrganizationWallet(organizationId); - const paraClient = initializeParaClient(); - - if (!(walletRecord.userShare && walletRecord.paraWalletId)) { - throw new Error( - "Wallet missing Para credentials (userShare or paraWalletId)" - ); - } - - const decryptedShare = decryptUserShare(walletRecord.userShare); - await paraClient.setUserShare(decryptedShare); - - const walletId = walletRecord.paraWalletId; - const address = walletRecord.walletAddress as Address; - - async function signRawHash(hash: Hex): Promise { - const res = await paraClient.signMessage({ - walletId, - messageBase64: hexToBase64(hash), - }); - if (!("signature" in res)) { - throw new Error("Para signing was denied"); - } - // Para returns v as yParity (0/1) but on-chain ECDSA expects v=27/28 - const sig = res.signature; - const vByte = Number.parseInt(sig.slice(-2), 16); - if (vByte < 27) { - const normalizedV = (vByte + 27).toString(16).padStart(2, "0"); - return `0x${sig.slice(0, -2)}${normalizedV}` as Hex; - } - return `0x${sig}` as Hex; - } - - const account: LocalAccount = { - address, - // publicKey is not recoverable from address alone; permissionless.js - // only uses the address field from the owner account - publicKey: "0x" as Hex, - source: "para" as string, - type: "local", - - async sign({ hash }: { hash: Hex }): Promise { - return await signRawHash(hash); - }, - - async signMessage({ message }: { message: SignableMessage }): Promise { - const hash = hashMessage(message); - return await signRawHash(hash); - }, - - async signTransaction(transaction, _options?): Promise { - const serialized = serializeTransaction(transaction); - const res = await paraClient.signMessage({ - walletId, - messageBase64: hexToBase64(serialized), - }); - if (!("signature" in res)) { - throw new Error("Para transaction signing was denied"); - } - const { r, s, v } = parseSignature(res.signature); - 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); - return await signRawHash(hash); - }, - - async signAuthorization( - authorization: AuthorizationRequest - ): Promise { - const hash = hashAuthorization(authorization); - const sigHex = await signRawHash(hash); - const { r, s, v } = parseSignature(sigHex); - 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/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 4755e0433..000000000 --- a/lib/web3/pimlico-config.ts +++ /dev/null @@ -1,49 +0,0 @@ -import "server-only"; -import type { Address } from "viem"; - -function getPimlicoBaseUrl(): string { - const url = process.env.PIMLICO_BASE_URL; - if (!url) { - throw new Error("PIMLICO_BASE_URL not configured"); - } - return url; -} - -/** - * Chain IDs where gas sponsorship via EIP-7702 + Pimlico is supported. - * Chains must support both EIP-7702 and have Pimlico bundler coverage. - */ -export const SUPPORTED_SPONSORSHIP_CHAINS: ReadonlySet = new Set([ - 8453, // Base - 84_532, // Base Sepolia - 10, // Optimism - 42_161, // Arbitrum One - 137, // Polygon - 1, // Ethereum Mainnet - 11_155_111, // Sepolia -]); - -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 e4e581fe8..f327bc4cd 100644 --- a/lib/web3/sponsored-client.ts +++ b/lib/web3/sponsored-client.ts @@ -1,139 +1,67 @@ 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 { createParaViemAccount } from "@/lib/para/viem-account-adapter"; -import { recordDelegationIfNeeded } from "@/lib/web3/eip7702-delegation"; -import { - getPimlicoUrl, - isSponsorshipSupported, -} from "@/lib/web3/pimlico-config"; -import { clampSponsoredFees } from "@/lib/web3/sponsored-fee-clamp"; - -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 Para MPC 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's active wallet is not a Turnkey wallet (legacy Para wallet) + * - the wallet row is missing required Turnkey identifiers * - * 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 createParaViemAccount(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({ + provider: organizationWallets.provider, + 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.provider !== "turnkey" || 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/turnkey-sponsored-tx.ts b/lib/web3/turnkey-sponsored-tx.ts new file mode 100644 index 000000000..eb537a73a --- /dev/null +++ b/lib/web3/turnkey-sponsored-tx.ts @@ -0,0 +1,174 @@ +import "server-only"; +import type { Hex } from "viem"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; +import { getTurnkeyClientForOrg } from "@/lib/turnkey/agentic-wallet"; +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, + // The SDK v5.2.0 CAIP-2 enum does not include Arbitrum (eip155:42161) + // even though it appears in Turnkey's Gas Station docs. Cast through + // a wider string type so we can probe Arbitrum at runtime; Turnkey + // will reject the activity if it is genuinely unsupported. + // biome-ignore lint/suspicious/noExplicitAny: SDK CAIP-2 enum lags Turnkey's actual chain coverage; runtime is authoritative + 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) { + try { + const response = await client.getSendTransactionStatus({ + organizationId: subOrgId, + sendTransactionStatusId, + }); + + if (TERMINAL_FAILURE_STATUSES.has(response.txStatus)) { + logSystemError( + ErrorCategory.EXTERNAL_SERVICE, + "[Turnkey Sponsorship] Transaction terminated with failure status", + new Error(response.txError ?? response.txStatus), + { + service: "turnkey", + send_transaction_status_id: sendTransactionStatusId, + tx_status: response.txStatus, + } + ); + return null; + } + + const hash = response.eth?.txHash; + if ( + TERMINAL_SUCCESS_STATUSES.has(response.txStatus) && + hash !== undefined && + hash !== "" + ) { + return hash as Hex; + } + } catch (error) { + logSystemError( + ErrorCategory.EXTERNAL_SERVICE, + "[Turnkey Sponsorship] getSendTransactionStatus failed", + error, + { + service: "turnkey", + send_transaction_status_id: sendTransactionStatusId, + } + ); + return null; + } + + 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..14289a46d --- /dev/null +++ b/lib/web3/turnkey-sponsorship-config.ts @@ -0,0 +1,42 @@ +import "server-only"; + +/** + * 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. + * + * Mainnet coverage is dictated by the CAIP-2 enum in + * @turnkey/sdk-server's `v1EthSendTransactionIntent.caip2`. Optimism is + * absent from Turnkey's Gas Station and was dropped from this allowlist + * during the Pimlico -> Turnkey migration (KEEP-464). Arbitrum is included + * per product request but is NOT present in the SDK v5.2.0 enum; the + * runtime request will fall through with a cast and may be rejected by + * Turnkey's API. Confirm with Turnkey support before enabling Arbitrum + * in production. + */ +export const SUPPORTED_SPONSORSHIP_CHAINS: ReadonlySet = new Set([ + 1, // Ethereum Mainnet + 11_155_111, // Sepolia + 8453, // Base + 84_532, // Base Sepolia + 137, // Polygon + 80_002, // Polygon Amoy + 42_161, // Arbitrum One -- pending Turnkey confirmation, see note above +]); + +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}`; +} From 2408b2d033e20bdcec500930048bdb9e8c89af27 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Mon, 11 May 2026 19:42:30 -0300 Subject: [PATCH 2/9] refactor(plugins/web3): point sponsored-path imports at Turnkey config Update the four web3 step files that gate sponsorship on chain support to import isSponsorshipSupported from turnkey-sponsorship-config instead of the deleted pimlico-config. Refresh inline comments to describe Turnkey Gas Station rather than ERC-4337 / Pimlico bundler mechanics. The KEEP-137 private-mempool exclusion still applies because Turnkey broadcasts via its own infrastructure, not through Flashbots. --- plugins/web3/steps/approve-token-core.ts | 8 ++++---- plugins/web3/steps/transfer-funds-core.ts | 6 +++--- plugins/web3/steps/transfer-token-core.ts | 8 ++++---- plugins/web3/steps/write-contract-core.ts | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/plugins/web3/steps/approve-token-core.ts b/plugins/web3/steps/approve-token-core.ts index 12283f637..2928808f9 100644 --- a/plugins/web3/steps/approve-token-core.ts +++ b/plugins/web3/steps/approve-token-core.ts @@ -25,7 +25,7 @@ import { generateId } from "@/lib/utils/id"; import { getChainAdapter } from "@/lib/web3/chain-adapter"; import { formatContractError } 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"; @@ -43,7 +43,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. @@ -212,9 +212,9 @@ 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. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. if ( isSponsorshipSupported(chainId) && !usePrivateMempool && diff --git a/plugins/web3/steps/transfer-funds-core.ts b/plugins/web3/steps/transfer-funds-core.ts index d8316e33c..7f434b7a4 100644 --- a/plugins/web3/steps/transfer-funds-core.ts +++ b/plugins/web3/steps/transfer-funds-core.ts @@ -38,7 +38,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. @@ -181,9 +181,9 @@ 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. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. if (!usePrivateMempool && 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, diff --git a/plugins/web3/steps/transfer-token-core.ts b/plugins/web3/steps/transfer-token-core.ts index 53262b94d..7a6bc1298 100644 --- a/plugins/web3/steps/transfer-token-core.ts +++ b/plugins/web3/steps/transfer-token-core.ts @@ -29,7 +29,7 @@ import { generateId } from "@/lib/utils/id"; import { getChainAdapter } from "@/lib/web3/chain-adapter"; import { formatContractError } 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"; @@ -46,7 +46,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. @@ -320,9 +320,9 @@ 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. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. if ( isSponsorshipSupported(chainId) && !usePrivateMempool && diff --git a/plugins/web3/steps/write-contract-core.ts b/plugins/web3/steps/write-contract-core.ts index a65c92b1f..3af130204 100644 --- a/plugins/web3/steps/write-contract-core.ts +++ b/plugins/web3/steps/write-contract-core.ts @@ -53,7 +53,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. @@ -285,9 +285,9 @@ 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. + // Turnkey broadcasts via its own infrastructure, which bypasses Flashbots Protect. if (!usePrivateMempool && isGasSponsorshipEnabled()) { try { const sponsoredResult = await executeSponsoredContractTransaction({ From 2110640cd2152f128aaa89bd516353028230d1b2 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Mon, 11 May 2026 19:42:30 -0300 Subject: [PATCH 3/9] feat(billing): surface Turnkey-sponsored networks on gas-credits card Add a one-line note under the gas-sponsorship credits bar listing the networks Turnkey Gas Station covers today: Ethereum, Base, Polygon, Arbitrum (plus Sepolia, Base Sepolia, and Polygon Amoy testnets). Lets users see at a glance which chains are eligible for sponsorship and which fall back to wallet-paid gas. --- components/billing/billing-status.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/billing/billing-status.tsx b/components/billing/billing-status.tsx index 6c4eb5bce..4e3e6c53c 100644 --- a/components/billing/billing-status.tsx +++ b/components/billing/billing-status.tsx @@ -476,6 +476,10 @@ function GasCreditsBar({ style={{ width: `${percent}%` }} /> +

+ Sponsored networks: Ethereum, Base, Polygon, Arbitrum (+ Sepolia, Base + Sepolia, Polygon Amoy testnets). +

{isExhausted && (

Gas credits exhausted. Transactions will use your wallet's ETH for From 0575f7586ad7853a08170efb892e3f8c2fd26f8e Mon Sep 17 00:00:00 2001 From: joelorzet Date: Mon, 11 May 2026 19:42:30 -0300 Subject: [PATCH 4/9] test(web3): retarget sponsorship tests at the Turnkey path Rewrite sponsored-client and sponsored-transaction-manager unit tests to mock the Turnkey config and ethSendTransaction wrapper instead of the Pimlico client and Para viem adapter. Update approve-token to point at turnkey-sponsorship-config. Delete sponsored-fee-clamp tests (the fee-clamp module is gone) and the eip7702-spike integration test, which exercised the Pimlico ERC-4337 path that no longer exists. --- tests/integration/eip7702-spike.d.ts | 8 - tests/integration/eip7702-spike.test.ts | 584 ------------------ tests/unit/approve-token.test.ts | 2 +- tests/unit/sponsored-client.test.ts | 212 +++---- tests/unit/sponsored-fee-clamp.test.ts | 81 --- .../sponsored-transaction-manager.test.ts | 74 ++- 6 files changed, 140 insertions(+), 821 deletions(-) delete mode 100644 tests/integration/eip7702-spike.d.ts delete mode 100644 tests/integration/eip7702-spike.test.ts delete mode 100644 tests/unit/sponsored-fee-clamp.test.ts diff --git a/tests/integration/eip7702-spike.d.ts b/tests/integration/eip7702-spike.d.ts deleted file mode 100644 index 9da4aeb0f..000000000 --- a/tests/integration/eip7702-spike.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module "@getpara/server-sdk/dist/esm/wallet/privateKey.js" { - export function getPrivateKey( - ctx: unknown, - userId: string, - walletId: string, - userShare: string - ): Promise; -} diff --git a/tests/integration/eip7702-spike.test.ts b/tests/integration/eip7702-spike.test.ts deleted file mode 100644 index 4d7d1f62a..000000000 --- a/tests/integration/eip7702-spike.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -/// - -import { afterAll, describe, expect, it, vi } from "vitest"; - -vi.mock("server-only", () => ({})); - -// Unmock the database -- setup.ts mocks @/lib/db globally, but this spike -// needs real DB access for Para wallet lookups -vi.unmock("@/lib/db"); - -import { getRpcProviderFromUrls } from "@/lib/rpc/provider-factory"; -import { getRpcUrlByChainId } from "@/lib/rpc/rpc-config"; - -// --------------------------------------------------------------------------- -// Environment gate -- entire suite skips if keys are missing -// --------------------------------------------------------------------------- -const PIMLICO_API_KEY = process.env.PIMLICO_API_KEY; -const PARA_API_KEY = process.env.PARA_API_KEY; - -const HAS_PIMLICO = Boolean(PIMLICO_API_KEY); -const HAS_PARA = Boolean(PARA_API_KEY); -const HAS_ALL = HAS_PIMLICO && HAS_PARA; - -// Base Sepolia -const CHAIN_ID = 84_532; - -// Build a failover-aware ethers provider for Base Sepolia. Used by every -// RPC read on the ethers code path so a primary-endpoint hiccup falls back -// to the secondary instead of failing the whole test. Note: viem-side -// callers (Pimlico bundler / publicClient below) do not go through this -- -// they use viem's own transport and Pimlico's bundler has independent -// retry semantics. -async function getBaseSepoliaManager(): ReturnType< - typeof getRpcProviderFromUrls -> { - return getRpcProviderFromUrls( - getRpcUrlByChainId(CHAIN_ID, "primary"), - getRpcUrlByChainId(CHAIN_ID, "fallback"), - CHAIN_ID, - "base-sepolia" - ); -} - -// Top-level regex patterns for lint compliance -const HEX_64_PATTERN = /^0x[a-f0-9]{64}$/; -const RAW_HEX_64_PATTERN = /^[a-f0-9]{64}$/i; - -// Pimlico's reference SimpleAccount7702 implementation (EntryPoint 0.8) -const SIMPLE_ACCOUNT_7702_ADDRESS = - "0xe6Cae83BdE06E4c305530e199D7217f42808555B"; - -// --------------------------------------------------------------------------- -// Scenario A: ethers type-4 serialization (pure unit test, no network) -// --------------------------------------------------------------------------- -describe("Scenario A: ethers type-4 serialization", () => { - it("builds a valid type-4 transaction with authorizationList", async () => { - const { ethers } = await import("ethers"); - - const wallet = ethers.Wallet.createRandom(); - const delegateAddress = "0x0000000000000000000000000000000000000001"; - - const authHash = ethers.hashAuthorization({ - chainId: CHAIN_ID, - address: delegateAddress, - nonce: 0, - }); - - expect(authHash).toMatch(HEX_64_PATTERN); - - const sig = wallet.signingKey.sign(authHash); - - const recovered = ethers.verifyAuthorization( - { chainId: CHAIN_ID, address: delegateAddress, nonce: 0 }, - { r: sig.r, s: sig.s, yParity: sig.yParity } - ); - expect(recovered.toLowerCase()).toBe(wallet.address.toLowerCase()); - - const tx = ethers.Transaction.from({ - type: 4, - chainId: CHAIN_ID, - to: wallet.address, - value: 0, - gasLimit: 21_000, - maxFeePerGas: ethers.parseUnits("1", "gwei"), - maxPriorityFeePerGas: ethers.parseUnits("1", "gwei"), - nonce: 0, - authorizationList: [ - { - chainId: BigInt(CHAIN_ID), - address: delegateAddress, - nonce: BigInt(0), - signature: ethers.Signature.from({ - r: sig.r, - s: sig.s, - yParity: sig.yParity, - }), - }, - ], - }); - - // Type-4 envelope starts with 0x04 - expect(tx.unsignedSerialized.startsWith("0x04")).toBe(true); - expect(tx.type).toBe(4); - expect(tx.authorizationList).toHaveLength(1); - }); - - it("hashAuthorization produces MAGIC || rlp([chainId, address, nonce])", async () => { - const { ethers } = await import("ethers"); - - const auth = { - chainId: CHAIN_ID, - address: SIMPLE_ACCOUNT_7702_ADDRESS, - nonce: 42, - }; - - const hash = ethers.hashAuthorization(auth); - - expect(hash).toHaveLength(66); - expect(hash).toMatch(HEX_64_PATTERN); - }); -}); - -// --------------------------------------------------------------------------- -// Scenario B: Para signer + type-4 transaction -// Skipped if PARA_API_KEY is not set. -// --------------------------------------------------------------------------- -describe.skipIf(!HAS_PARA)("Scenario B: Para signer accepts type-4 RLP", () => { - it("attempts to sign a type-4 transaction via ParaEthersSigner", { - timeout: 60_000, - }, async () => { - const { ethers } = await import("ethers"); - const { ParaEthersSigner } = await import("@getpara/ethers-v6-integration"); - const { Environment, Para: ParaServer } = await import( - "@getpara/server-sdk" - ); - const { decryptUserShare } = await import("@/lib/encryption"); - const { getOrganizationWallet } = await import("@/lib/para/wallet-helpers"); - - const orgId = process.env.TEST_ORG_ID; - if (!orgId) { - console.log( - "SKIP: TEST_ORG_ID not set -- cannot test Para type-4 signing" - ); - return; - } - - const wallet = await getOrganizationWallet(orgId); - - const paraClient = new ParaServer( - process.env.PARA_ENVIRONMENT === "prod" - ? Environment.PROD - : Environment.BETA, - PARA_API_KEY ?? "" - ); - - if (!wallet.userShare) { - throw new Error("Wallet missing userShare"); - } - const decryptedShare = decryptUserShare(wallet.userShare); - await paraClient.setUserShare(decryptedShare); - await paraClient.setUserId(wallet.userId); - - // ParaEthersSigner expects a single ethers.Provider; this test only asks - // the signer to sign (no broadcast), so attaching the failover manager's - // current primary provider is sufficient. If the test is later extended - // to broadcast, wrap the broadcast call in manager.executeWithFailover. - const manager = await getBaseSepoliaManager(); - const provider = manager.getProvider(); - const paraSigner = new ParaEthersSigner(paraClient as any, provider); - const signerAddress = await paraSigner.getAddress(); - - // Local key for authorization signing -- we're testing Para's ability - // to sign the outer type-4 tx, not auth tuple signing - const localWallet = ethers.Wallet.createRandom(); - const authHash = ethers.hashAuthorization({ - chainId: CHAIN_ID, - address: SIMPLE_ACCOUNT_7702_ADDRESS, - nonce: 0, - }); - const authSig = localWallet.signingKey.sign(authHash); - - const txRequest = { - type: 4, - chainId: CHAIN_ID, - to: signerAddress, - value: 0, - gasLimit: 100_000, - maxFeePerGas: ethers.parseUnits("1", "gwei"), - maxPriorityFeePerGas: ethers.parseUnits("1", "gwei"), - nonce: 0, - authorizationList: [ - { - chainId: CHAIN_ID, - address: SIMPLE_ACCOUNT_7702_ADDRESS, - nonce: 0, - signature: ethers.Signature.from({ - r: authSig.r, - s: authSig.s, - yParity: authSig.yParity, - }), - }, - ], - }; - - try { - const signedTx = await paraSigner.signTransaction(txRequest); - console.log("RESULT B: Para ACCEPTED type-4 transaction"); - console.log(" Signed tx length:", signedTx.length); - expect(signedTx).toBeTruthy(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.log("RESULT B: Para REJECTED type-4 transaction"); - console.log(" Error:", message); - // Expected failure -- tells us Para cannot handle type-4 - // and we need the private key extraction path - expect(message).toBeTruthy(); - } - }); -}); - -// --------------------------------------------------------------------------- -// Scenario C: Private key extraction + full EIP-7702 sponsorship -// Skipped if either PIMLICO_API_KEY or PARA_API_KEY is missing. -// --------------------------------------------------------------------------- -describe.skipIf(!HAS_ALL)( - "Scenario C: Private key extraction + Pimlico sponsorship", - () => { - let extractedAddress: string | undefined; - let privateKeyHex: string | undefined; - - afterAll(() => { - if (privateKeyHex) { - privateKeyHex = undefined; - } - }); - - it("C.a: extracts private key from Para and verifies address match", { - timeout: 60_000, - }, async () => { - const { ethers } = await import("ethers"); - const { Environment, Para: ParaServer } = await import( - "@getpara/server-sdk" - ); - const { getPrivateKey } = await import( - "@getpara/server-sdk/dist/esm/wallet/privateKey.js" - ); - const { decryptUserShare } = await import("@/lib/encryption"); - const { getOrganizationWallet } = await import( - "@/lib/para/wallet-helpers" - ); - - const orgId = process.env.TEST_ORG_ID; - if (!orgId) { - console.log("SKIP: TEST_ORG_ID not set -- cannot test key extraction"); - return; - } - - const walletRecord = await getOrganizationWallet(orgId); - if (!(walletRecord.userShare && walletRecord.paraWalletId)) { - throw new Error("Wallet missing Para credentials"); - } - const decryptedShare = decryptUserShare(walletRecord.userShare); - - const paraEnv = - process.env.PARA_ENVIRONMENT === "prod" - ? Environment.PROD - : Environment.BETA; - - const paraClient = new ParaServer(paraEnv, PARA_API_KEY ?? ""); - await paraClient.setUserShare(decryptedShare); - await paraClient.setUserId(walletRecord.userId); - - // Use the Para client's internal ctx which has the HTTP client - // configured for proper server authentication - const ctx = (paraClient as unknown as Record).ctx; - - try { - privateKeyHex = await getPrivateKey( - ctx, - walletRecord.userId, - walletRecord.paraWalletId, - decryptedShare - ); - - expect(privateKeyHex).toMatch(RAW_HEX_64_PATTERN); - - const localWallet = new ethers.Wallet(`0x${privateKeyHex}`); - extractedAddress = localWallet.address; - - console.log("RESULT C.a: Private key extraction SUCCEEDED"); - console.log(" Extracted address:", extractedAddress); - console.log(" DB wallet address:", walletRecord.walletAddress); - console.log( - " Match:", - extractedAddress.toLowerCase() === - walletRecord.walletAddress.toLowerCase() - ); - - expect(extractedAddress.toLowerCase()).toBe( - walletRecord.walletAddress.toLowerCase() - ); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.log("RESULT C.a: Private key extraction FAILED"); - console.log(" Error:", message); - - // Para's MPC protocol requires server-side session auth. - // Pregen wallets created in a different session context will - // fail with "user must be authenticated". This is an - // environment/session issue, not a code issue -- getPrivateKey - // is a valid API that works with properly authenticated sessions. - if (message.includes("user must be authenticated")) { - console.log( - " NOTE: Para session auth required. Test wallet needs active session." - ); - console.log( - " This does NOT mean getPrivateKey is broken -- it needs", - "a wallet created in the current API key's session." - ); - return; - } - throw error; - } - }); - - it("C.b: signs EIP-7702 authorization tuple with extracted key", { - timeout: 30_000, - }, async () => { - if (!(privateKeyHex && extractedAddress)) { - console.log("SKIP: C.a did not produce a private key"); - return; - } - - const { ethers } = await import("ethers"); - - const manager = await getBaseSepoliaManager(); - const accountNonce = await manager.executeWithFailover((p) => - p.getTransactionCount(extractedAddress as string) - ); - - const authHash = ethers.hashAuthorization({ - chainId: CHAIN_ID, - address: SIMPLE_ACCOUNT_7702_ADDRESS, - nonce: accountNonce, - }); - - const wallet = new ethers.Wallet(`0x${privateKeyHex}`); - const sig = wallet.signingKey.sign(authHash); - - const recovered = ethers.verifyAuthorization( - { - chainId: CHAIN_ID, - address: SIMPLE_ACCOUNT_7702_ADDRESS, - nonce: accountNonce, - }, - { r: sig.r, s: sig.s, yParity: sig.yParity } - ); - - console.log("RESULT C.b: Authorization signing SUCCEEDED"); - console.log(" Account nonce:", accountNonce); - console.log(" Recovered address:", recovered); - console.log( - " Match:", - recovered.toLowerCase() === extractedAddress.toLowerCase() - ); - - expect(recovered.toLowerCase()).toBe(extractedAddress.toLowerCase()); - }); - - it("C.c: submits sponsored EIP-7702 tx via Pimlico (ERC-4337 hybrid)", { - timeout: 120_000, - }, async () => { - if (!(privateKeyHex && extractedAddress)) { - console.log("SKIP: C.a did not produce a private key"); - return; - } - - const { createPublicClient, http } = await import("viem"); - const { privateKeyToAccount } = await import("viem/accounts"); - const { baseSepolia } = await import("viem/chains"); - const { createSmartAccountClient } = await import("permissionless"); - const { to7702SimpleSmartAccount } = await import( - "permissionless/accounts" - ); - const { createPimlicoClient } = await import( - "permissionless/clients/pimlico" - ); - - const startTime = Date.now(); - - const viemAccount = privateKeyToAccount( - `0x${privateKeyHex}` as `0x${string}` - ); - - const publicClient = createPublicClient({ - chain: baseSepolia, - transport: http("https://base-sepolia-rpc.publicnode.com"), - }); - - const pimlicoUrl = `https://api.pimlico.io/v2/${CHAIN_ID}/rpc?apikey=${PIMLICO_API_KEY}`; - - const pimlicoClient = createPimlicoClient({ - transport: http(pimlicoUrl), - entryPoint: { - address: - "0x0000000071727De22E5E9d8BAf0edAc6f37da032" as `0x${string}`, - version: "0.7", - }, - }); - - try { - const smartAccount = await to7702SimpleSmartAccount({ - client: publicClient, - owner: viemAccount, - }); - - console.log(" Smart account address:", smartAccount.address); - console.log(" EOA address:", viemAccount.address); - console.log( - " Addresses match (expected for 7702):", - smartAccount.address.toLowerCase() === - viemAccount.address.toLowerCase() - ); - - const gasPrices = await pimlicoClient.getUserOperationGasPrice(); - - const smartAccountClient = createSmartAccountClient({ - account: smartAccount, - chain: baseSepolia, - bundlerTransport: http(pimlicoUrl), - paymaster: pimlicoClient, - userOperation: { - estimateFeesPerGas: async () => gasPrices.fast, - }, - }); - - const balanceBefore = await publicClient.getBalance({ - address: viemAccount.address, - }); - - const txHash = await smartAccountClient.sendTransaction({ - to: viemAccount.address, - value: BigInt(0), - data: "0x" as `0x${string}`, - }); - - const receipt = await publicClient.waitForTransactionReceipt({ - hash: txHash, - }); - - const balanceAfter = await publicClient.getBalance({ - address: viemAccount.address, - }); - - const latencyMs = Date.now() - startTime; - const gasUsed = receipt.gasUsed; - const effectiveGasPrice = receipt.effectiveGasPrice; - const gasCostWei = gasUsed * effectiveGasPrice; - const userPaidGas = balanceBefore - balanceAfter; - - console.log("RESULT C.c: Sponsored EIP-7702 tx SUCCEEDED"); - console.log(" Tx hash:", txHash); - console.log(" Block:", receipt.blockNumber); - console.log(" Gas used:", gasUsed.toString()); - console.log( - " Effective gas price (wei):", - effectiveGasPrice.toString() - ); - console.log(" Total gas cost (wei):", gasCostWei.toString()); - console.log(" User ETH change (wei):", userPaidGas.toString()); - console.log(" User paid zero gas:", userPaidGas === BigInt(0)); - console.log(" Latency (ms):", latencyMs); - - expect(userPaidGas).toBe(BigInt(0)); - expect(receipt.status).toBe("success"); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.log("RESULT C.c: Sponsored EIP-7702 tx FAILED"); - console.log(" Error:", message); - if (error instanceof Error && error.stack) { - console.log( - " Stack:", - error.stack.split("\n").slice(0, 5).join("\n") - ); - } - throw error; - } - }); - } -); - -// --------------------------------------------------------------------------- -// Scenario D: ERC-4337 pure fallback (standard smart account, no EIP-7702) -// Only relevant if Scenarios B+C fail. Tests that Para's signTypedData -// works with ERC-4337 UserOperation signing. -// --------------------------------------------------------------------------- -describe.skipIf(!HAS_ALL)( - "Scenario D: ERC-4337 pure fallback (no EIP-7702)", - () => { - it("D: creates and submits a sponsored UserOp via standard smart account", { - timeout: 120_000, - }, async () => { - const { createPublicClient, http } = await import("viem"); - const { privateKeyToAccount } = await import("viem/accounts"); - const { baseSepolia } = await import("viem/chains"); - const { createSmartAccountClient } = await import("permissionless"); - const { toSimpleSmartAccount } = await import("permissionless/accounts"); - const { createPimlicoClient } = await import( - "permissionless/clients/pimlico" - ); - - // Throwaway key -- testing ERC-4337 pipeline, not Para integration - const throwawayAccount = privateKeyToAccount( - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as `0x${string}` - ); - - const publicClient = createPublicClient({ - chain: baseSepolia, - transport: http("https://base-sepolia-rpc.publicnode.com"), - }); - - const pimlicoUrl = `https://api.pimlico.io/v2/${CHAIN_ID}/rpc?apikey=${PIMLICO_API_KEY}`; - - const pimlicoClient = createPimlicoClient({ - transport: http(pimlicoUrl), - entryPoint: { - address: - "0x0000000071727De22E5E9d8BAf0edAc6f37da032" as `0x${string}`, - version: "0.7", - }, - }); - - const startTime = Date.now(); - - try { - const smartAccount = await toSimpleSmartAccount({ - client: publicClient, - owner: throwawayAccount, - }); - - console.log(" Smart account address:", smartAccount.address); - - // Fetch gas prices from Pimlico to satisfy paymaster validation - const gasPrices = await pimlicoClient.getUserOperationGasPrice(); - - const smartAccountClient = createSmartAccountClient({ - account: smartAccount, - chain: baseSepolia, - bundlerTransport: http(pimlicoUrl), - paymaster: pimlicoClient, - userOperation: { - estimateFeesPerGas: async () => gasPrices.fast, - }, - }); - - const txHash = await smartAccountClient.sendTransaction({ - to: smartAccount.address, - value: BigInt(0), - data: "0x" as `0x${string}`, - }); - - const receipt = await publicClient.waitForTransactionReceipt({ - hash: txHash, - }); - - const latencyMs = Date.now() - startTime; - - console.log("RESULT D: ERC-4337 fallback SUCCEEDED"); - console.log(" Tx hash:", txHash); - console.log(" Block:", receipt.blockNumber); - console.log(" Gas used:", receipt.gasUsed.toString()); - console.log(" Latency (ms):", latencyMs); - console.log(" Status:", receipt.status); - - expect(receipt.status).toBe("success"); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.log("RESULT D: ERC-4337 fallback FAILED"); - console.log(" Error:", message); - throw error; - } - }); - } -); diff --git a/tests/unit/approve-token.test.ts b/tests/unit/approve-token.test.ts index 7c53b60e1..e07894737 100644 --- a/tests/unit/approve-token.test.ts +++ b/tests/unit/approve-token.test.ts @@ -161,7 +161,7 @@ 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, })); diff --git a/tests/unit/sponsored-client.test.ts b/tests/unit/sponsored-client.test.ts index 6e12ce2b2..e4f7c683b 100644 --- a/tests/unit/sponsored-client.test.ts +++ b/tests/unit/sponsored-client.test.ts @@ -1,158 +1,112 @@ -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/para/viem-account-adapter", () => ({ - createParaViemAccount: 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: { + provider: "provider", + 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 for a legacy Para wallet so callers fall back to direct signing", async () => { + mockIsSponsorshipSupported.mockReturnValue(true); + mockLimit.mockResolvedValue([ + { + provider: "para", + walletAddress: "0xabc", + turnkeySubOrgId: null, + }, + ]); + + const result = await createSponsoredClient("org_1", 1); - const { result, args } = await buildSponsoredClient(); - expect(result).not.toBeNull(); - const estimateFeesPerGas = args?.userOperation?.estimateFeesPerGas; - expect(estimateFeesPerGas).toBeTypeOf("function"); + expect(result).toBeNull(); + }); - const fees = await (estimateFeesPerGas as EstimateFeesPerGas)(); + it("returns null when a Turnkey wallet row is missing the sub-org id", async () => { + mockIsSponsorshipSupported.mockReturnValue(true); + mockLimit.mockResolvedValue([ + { + provider: "turnkey", + walletAddress: "0xabc", + turnkeySubOrgId: null, + }, + ]); - expect(fees.maxFeePerGas).toBe(BigInt(50_000_000_000)); - expect(fees.maxPriorityFeePerGas).toBe(BigInt(2_000_000_000)); + const result = await createSponsoredClient("org_1", 1); + + expect(result).toBeNull(); + }); + + it("returns the Turnkey identifiers when the org wallet is fully provisioned", async () => { + mockIsSponsorshipSupported.mockReturnValue(true); + mockLimit.mockResolvedValue([ + { + provider: "turnkey", + walletAddress: "0xabc", + turnkeySubOrgId: "suborg-123", + }, + ]); + + const result = await createSponsoredClient("org_1", 8453); + + 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", + }) + ); + }); }); From 6bbc7148d8a45df0c2f27e4f9ff825d38c010e0b Mon Sep 17 00:00:00 2001 From: joelorzet Date: Mon, 11 May 2026 19:51:55 -0300 Subject: [PATCH 5/9] feat(billing): surface Turnkey sponsorship in status card and pricing table Gas-credits card on the billing status page gets a dedicated "Sponsored networks" section under the usage bar, with mainnet badges (Ethereum, Base, Polygon, Arbitrum) always visible and an Info-icon tooltip listing testnets and the off-list fallback behavior. Plan comparison table gains a "Sponsored gas" row right after "Triggers". The Info-icon tooltip beside the label lists the Turnkey mainnets and testnets; cell values show the per-plan monthly USD cap (resolved from the live gasCreditCaps env overrides, falling back to PLANS defaults), so the comparison table and the per-plan cards stay in sync. --- components/billing/billing-status.tsx | 61 ++++++++++- components/billing/pricing-table/index.tsx | 120 +++++++++++++++++++-- 2 files changed, 168 insertions(+), 13 deletions(-) diff --git a/components/billing/billing-status.tsx b/components/billing/billing-status.tsx index 4e3e6c53c..1209d7747 100644 --- a/components/billing/billing-status.tsx +++ b/components/billing/billing-status.tsx @@ -1,15 +1,24 @@ "use client"; +import { Info } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { BILLING_ALERTS, BILLING_API } from "@/lib/billing/constants"; import { PLANS, type PlanName } from "@/lib/billing/plans"; 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; @@ -462,7 +471,7 @@ function GasCreditsBar({ const barColor = resolveBarColor(); return ( -

+
Gas sponsorship credits @@ -476,10 +485,7 @@ function GasCreditsBar({ style={{ width: `${percent}%` }} />
-

- Sponsored networks: Ethereum, Base, Polygon, Arbitrum (+ Sepolia, Base - Sepolia, Polygon Amoy testnets). -

+ {isExhausted && (

Gas credits exhausted. Transactions will use your wallet's ETH for @@ -490,6 +496,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 8561f5d48..1adfed663 100644 --- a/components/billing/pricing-table/index.tsx +++ b/components/billing/pricing-table/index.tsx @@ -1,15 +1,79 @@ "use client"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Info } from "lucide-react"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; -import type { BillingInterval } from "@/lib/billing/plans"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { BillingInterval, PlanName } from "@/lib/billing/plans"; import { PLANS } from "@/lib/billing/plans"; import { cn } from "@/lib/utils"; import { PlanCard } from "./plan-card"; import type { PricingTableProps } from "./types"; -const COMPARISON_ROWS = [ +const SPONSORED_MAINNETS = ["Ethereum", "Base", "Polygon", "Arbitrum"] as const; +const SPONSORED_TESTNETS = ["Sepolia", "Base Sepolia", "Polygon Amoy"] as const; + +type ComparisonRow = { + label: string; + tooltip?: { + title: string; + body: React.ReactNode; + }; + free: string; + pro: string; + business: string; + enterprise: string; +}; + +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 +

+

{SPONSORED_MAINNETS.join(", ")}

+
+
+

+ Testnets +

+

{SPONSORED_TESTNETS.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 STATIC_COMPARISON_ROWS: readonly ComparisonRow[] = [ { label: "Workflows", free: "Unlimited", @@ -80,11 +144,51 @@ const COMPARISON_ROWS = [ business: "\u2014", enterprise: "Dedicated", }, -] as const; +]; + +function ComparisonRowLabel({ + row, +}: { + row: ComparisonRow; +}): React.ReactElement { + if (!row.tooltip) { + return <>{row.label}; + } + return ( + + {row.label} + + + + + +

{row.tooltip.title}

+ {row.tooltip.body} +
+
+
+ ); +} -function ComparisonTable(): React.ReactElement { +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 (
- +

Paid tiers bill overage at the end of the cycle. Free tier caps at its From 5629b13ed40a49a53006e996259a2599a78dcfc2 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Mon, 11 May 2026 20:16:36 -0300 Subject: [PATCH 6/9] docs(web3): record Turnkey's confirmation that Arbitrum is supported Turnkey support confirmed Ethereum, Base, Polygon, and Arbitrum on mainnet for sponsored EVM transactions. Drop the speculative hedging from the chain-allowlist comment and from the runtime cast at the ethSendTransaction call site. The cast itself stays in place because the @turnkey/sdk-server v5.2.0 CAIP-2 enum has not been regenerated to include eip155:42161; once it does, the cast can be removed. --- lib/web3/turnkey-sponsored-tx.ts | 10 +++++----- lib/web3/turnkey-sponsorship-config.ts | 16 +++++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/web3/turnkey-sponsored-tx.ts b/lib/web3/turnkey-sponsored-tx.ts index eb537a73a..34c370731 100644 --- a/lib/web3/turnkey-sponsored-tx.ts +++ b/lib/web3/turnkey-sponsored-tx.ts @@ -75,11 +75,11 @@ export async function submitTurnkeySponsoredTransaction( organizationId: params.subOrgId, from: params.walletAddress, sponsor: true, - // The SDK v5.2.0 CAIP-2 enum does not include Arbitrum (eip155:42161) - // even though it appears in Turnkey's Gas Station docs. Cast through - // a wider string type so we can probe Arbitrum at runtime; Turnkey - // will reject the activity if it is genuinely unsupported. - // biome-ignore lint/suspicious/noExplicitAny: SDK CAIP-2 enum lags Turnkey's actual chain coverage; runtime is authoritative + // 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(), diff --git a/lib/web3/turnkey-sponsorship-config.ts b/lib/web3/turnkey-sponsorship-config.ts index 14289a46d..3733e9bca 100644 --- a/lib/web3/turnkey-sponsorship-config.ts +++ b/lib/web3/turnkey-sponsorship-config.ts @@ -6,14 +6,12 @@ import "server-only"; * EVM transaction in a single API call -- there is no ERC-4337 bundler or * paymaster involved. * - * Mainnet coverage is dictated by the CAIP-2 enum in - * @turnkey/sdk-server's `v1EthSendTransactionIntent.caip2`. Optimism is - * absent from Turnkey's Gas Station and was dropped from this allowlist - * during the Pimlico -> Turnkey migration (KEEP-464). Arbitrum is included - * per product request but is NOT present in the SDK v5.2.0 enum; the - * runtime request will fall through with a cast and may be rejected by - * Turnkey's API. Confirm with Turnkey support before enabling Arbitrum - * in production. + * Mainnet coverage confirmed by Turnkey: Ethereum, Base, Polygon, Arbitrum. + * Optimism is absent from Turnkey's Gas Station and was dropped from this + * allowlist during the Pimlico -> Turnkey migration (KEEP-464). 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 = new Set([ 1, // Ethereum Mainnet @@ -22,7 +20,7 @@ export const SUPPORTED_SPONSORSHIP_CHAINS: ReadonlySet = new Set([ 84_532, // Base Sepolia 137, // Polygon 80_002, // Polygon Amoy - 42_161, // Arbitrum One -- pending Turnkey confirmation, see note above + 42_161, // Arbitrum One ]); export function isSponsorshipSupported(chainId: number): boolean { From 3a221ef972979c065bb8e548ccbce0bb3e8f2000 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Mon, 11 May 2026 20:30:35 -0300 Subject: [PATCH 7/9] feat(web3): surface Turnkey post-broadcast revert details Use the structured revert info Turnkey returns on getSendTransactionStatus to give users the real revert reason and stop falling back to direct signing when the underlying call has already failed on-chain. A new SponsoredTxRevertError carries the txHash, sendTransactionStatusId, and the v1RevertChainEntry list from Turnkey. The polling wrapper raises this error only when a txHash is present (the call is already mined); pre-broadcast failures (policy denial, gas-cap exhaustion, simulation errors) still yield null so callers fall through to direct signing as before. formatRevertChain walks the chain outermost-to-innermost and prefers decoded custom error names plus their JSON params, then native Solidity messages, then the raw 4-byte selector, with "execution reverted" as the last-resort fallback. The four web3 plugin steps that try sponsorship log the revert with txHash, sendTransactionStatusId, and revert_chain_depth as metadata, then short-circuit with the formatted revert message instead of falling back. Users do not pay gas twice on a call that is guaranteed to revert again, and operators still see every sponsored revert in Sentry / log search. --- lib/web3/turnkey-revert.ts | 110 +++++++++++++++++++++ lib/web3/turnkey-sponsored-tx.ts | 78 ++++++++++----- plugins/web3/steps/approve-token-core.ts | 20 ++++ plugins/web3/steps/transfer-funds-core.ts | 23 +++++ plugins/web3/steps/transfer-token-core.ts | 20 ++++ plugins/web3/steps/write-contract-core.ts | 20 ++++ tests/unit/turnkey-revert.test.ts | 111 ++++++++++++++++++++++ 7 files changed, 358 insertions(+), 24 deletions(-) create mode 100644 lib/web3/turnkey-revert.ts create mode 100644 tests/unit/turnkey-revert.test.ts 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 index 34c370731..d54489765 100644 --- a/lib/web3/turnkey-sponsored-tx.ts +++ b/lib/web3/turnkey-sponsored-tx.ts @@ -2,6 +2,11 @@ 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"; /** @@ -117,34 +122,12 @@ async function pollForTxHash( const deadline = Date.now() + STATUS_POLL_TIMEOUT_MS; while (Date.now() < deadline) { + let response: Awaited>; try { - const response = await client.getSendTransactionStatus({ + response = await client.getSendTransactionStatus({ organizationId: subOrgId, sendTransactionStatusId, }); - - if (TERMINAL_FAILURE_STATUSES.has(response.txStatus)) { - logSystemError( - ErrorCategory.EXTERNAL_SERVICE, - "[Turnkey Sponsorship] Transaction terminated with failure status", - new Error(response.txError ?? response.txStatus), - { - service: "turnkey", - send_transaction_status_id: sendTransactionStatusId, - tx_status: response.txStatus, - } - ); - return null; - } - - const hash = response.eth?.txHash; - if ( - TERMINAL_SUCCESS_STATUSES.has(response.txStatus) && - hash !== undefined && - hash !== "" - ) { - return hash as Hex; - } } catch (error) { logSystemError( ErrorCategory.EXTERNAL_SERVICE, @@ -158,6 +141,53 @@ async function pollForTxHash( 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); } diff --git a/plugins/web3/steps/approve-token-core.ts b/plugins/web3/steps/approve-token-core.ts index 2928808f9..fe7ef2765 100644 --- a/plugins/web3/steps/approve-token-core.ts +++ b/plugins/web3/steps/approve-token-core.ts @@ -29,6 +29,7 @@ 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, @@ -285,6 +286,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", diff --git a/plugins/web3/steps/transfer-funds-core.ts b/plugins/web3/steps/transfer-funds-core.ts index 7f434b7a4..f637f9b0e 100644 --- a/plugins/web3/steps/transfer-funds-core.ts +++ b/plugins/web3/steps/transfer-funds-core.ts @@ -27,6 +27,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, @@ -224,6 +225,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", diff --git a/plugins/web3/steps/transfer-token-core.ts b/plugins/web3/steps/transfer-token-core.ts index 7a6bc1298..792ff016e 100644 --- a/plugins/web3/steps/transfer-token-core.ts +++ b/plugins/web3/steps/transfer-token-core.ts @@ -33,6 +33,7 @@ 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, @@ -384,6 +385,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", diff --git a/plugins/web3/steps/write-contract-core.ts b/plugins/web3/steps/write-contract-core.ts index 3af130204..8eaa1ec81 100644 --- a/plugins/web3/steps/write-contract-core.ts +++ b/plugins/web3/steps/write-contract-core.ts @@ -34,6 +34,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, @@ -333,6 +334,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", 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' + ); + }); +}); From 7ad2c3f4776cfecceff831ce3fead3845626ac70 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Wed, 3 Jun 2026 13:51:14 -0300 Subject: [PATCH 8/9] refactor(web3): extract SIGNER_MODE constant for signer-mode discriminants Replace the bare "eoa" / "safe" / "safe-role" string literals with a single SIGNER_MODE constant (and SignerModeKind type) exported from the signer resolver. The signer-mode type, its constructors, the web3Connection parser, the metrics recorder, and the four web3 write steps now all reference one source instead of repeating the literals. --- lib/metrics/instrumentation/safe.ts | 3 +- lib/safe/signer-resolver.ts | 56 ++++++++++++++--------- plugins/web3/steps/approve-token-core.ts | 8 ++-- plugins/web3/steps/transfer-funds-core.ts | 10 ++-- plugins/web3/steps/transfer-token-core.ts | 10 ++-- plugins/web3/steps/write-contract-core.ts | 8 ++-- tests/unit/approve-token.test.ts | 1 + tests/unit/write-contract-core.test.ts | 1 + 8 files changed, 56 insertions(+), 41 deletions(-) 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/plugins/web3/steps/approve-token-core.ts b/plugins/web3/steps/approve-token-core.ts index 658ba0054..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, @@ -258,7 +258,7 @@ export async function approveTokenCore( if ( isSponsorshipSupported(chainId) && !usePrivateMempool && - signerMode.kind === "eoa" && + signerMode.kind === SIGNER_MODE.EOA && isGasSponsorshipEnabled() ) { try { @@ -409,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, { @@ -429,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 fa1eea628..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, @@ -220,7 +220,7 @@ export async function transferFundsCore( // 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 via Turnkey Gas Station (KEEP-464) @@ -321,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( @@ -349,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, { @@ -367,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 f696bba43..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, @@ -357,7 +357,7 @@ export async function transferTokenCore( if ( isSponsorshipSupported(chainId) && !usePrivateMempool && - signerMode.kind === "eoa" && + signerMode.kind === SIGNER_MODE.EOA && isGasSponsorshipEnabled() ) { try { @@ -470,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; @@ -507,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, { @@ -527,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 75027bea2..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, @@ -336,7 +336,7 @@ export async function writeContractCore( // which would change msg.sender away from the Safe. if ( !usePrivateMempool && - signerMode.kind === "eoa" && + signerMode.kind === SIGNER_MODE.EOA && isGasSponsorshipEnabled() ) { try { @@ -440,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, { @@ -461,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 4417863d0..d188599ee 100644 --- a/tests/unit/approve-token.test.ts +++ b/tests/unit/approve-token.test.ts @@ -166,6 +166,7 @@ vi.mock("@/lib/web3/turnkey-sponsorship-config", () => ({ })); 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/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", From 6337d50ae29caa2ead3a658240fbe293902f93e0 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Wed, 3 Jun 2026 13:56:54 -0300 Subject: [PATCH 9/9] chore(web3): remove leftover Pimlico/ERC-4337 sponsorship plumbing Complete the Pimlico -> Turnkey gas sponsorship migration by clearing the ERC-4337 remnants the staging merge carried back in: - delete the orphaned turnkey-viem-account helper (the permissionless.js smart-account bridge, no longer imported by anything) - drop the unused PIMLICO_API_KEY / PIMLICO_BASE_URL / SIMPLE_ACCOUNT_7702_ADDRESS env wiring from .env.example and the executor/keeperhub deploy values - refresh stale comments that still pointed at the Pimlico sponsorship path The eip7702_delegations table is left in place; it no longer has any writer and should be dropped in a dedicated migration. The unused `permissionless` package is also still in package.json -- removing it needs a lockfile re-resolve that the release-age policy currently blocks. --- .env.example | 5 - deploy/executor/prod/values.yaml | 13 --- deploy/executor/staging/values.yaml | 13 --- deploy/keeperhub/prod/values.yaml | 13 --- deploy/keeperhub/staging/values.yaml | 13 --- lib/agentic-wallet/policy.ts | 6 +- lib/db/schema-extensions.ts | 8 +- lib/web3/chainlink-feeds.ts | 2 +- lib/web3/turnkey-viem-account.ts | 167 --------------------------- 9 files changed, 9 insertions(+), 231 deletions(-) delete mode 100644 lib/web3/turnkey-viem-account.ts 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/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 c82541d99..c030f0195 100644 --- a/deploy/keeperhub/prod/values.yaml +++ b/deploy/keeperhub/prod/values.yaml @@ -412,19 +412,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 ec4109ddf..effb06b81 100644 --- a/deploy/keeperhub/staging/values.yaml +++ b/deploy/keeperhub/staging/values.yaml @@ -410,19 +410,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/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/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 }; -}