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