Turnkey Gas Station SDK
⚠️ BETA WARNING: This SDK is currently in beta. The underlying smart contracts are unaudited and should not be used in production environments. Use at your own risk.
A reusable SDK for implementing gasless transactions using EIP-7702, Turnkey wallet management, and your own paymaster. This package provides clean abstractions and utility methods to quickly integrate with Turnkey's contracts for sponsored transaction execution.
What is This?
This SDK enables you to:
- Bring your own paymaster to sponsor user transactions
- Use Turnkey for secure wallet management and transaction signing
- Execute gasless transactions via EIP-7702 delegation and EIP-712 signed intents
- Support any on-chain action through generic execution parameters
Perfect for building dApps where users don't need ETH for gas, enabling seamless onboarding and better UX.
How It Works
- EIP-7702 Authorization: One-time setup where an EOA authorizes a gas station contract
- EIP-712 Signed Intents: User signs off-chain intents for what they want to execute
- Paymaster Execution: Your paymaster submits the transaction and pays for gas
- Turnkey Integration: All signatures handled securely through Turnkey
Quick Start
1. Install Dependencies
pnpm install @turnkey/gas-station @turnkey/sdk-server @turnkey/viem viem
2. Set Up Environment
Create .env.local
:
BASE_URL=https://api.turnkey.com
API_PRIVATE_KEY=your_turnkey_api_private_key
API_PUBLIC_KEY=your_turnkey_api_public_key
ORGANIZATION_ID=your_turnkey_organization_id
EOA_ADDRESS=0x...
PAYMASTER_ADDRESS=0x...
BASE_RPC_URL=https://mainnet.base.org
ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/...
DELEGATE_CONTRACT=0x...
EXECUTION_CONTRACT=0x...
Note: The gas station contracts are currently deployed at deterministic addresses on the following chains:
- Ethereum Mainnet
- Base Mainnet
These addresses are built into the SDK, so you don't need to specify them unless you are using custom deployments.
3. Initialize and Use
import { GasStationClient } from "@turnkey/gas-station";
import { Turnkey } from "@turnkey/sdk-server";
import { createAccount } from "@turnkey/viem";
import { parseEther, parseUnits, createWalletClient, http } from "viem";
import { base } from "viem/chains";
const turnkeyClient = new Turnkey({
apiBaseUrl: process.env.BASE_URL!,
apiPrivateKey: process.env.API_PRIVATE_KEY!,
apiPublicKey: process.env.API_PUBLIC_KEY!,
defaultOrganizationId: process.env.ORGANIZATION_ID!,
});
const userAccount = await createAccount({
client: turnkeyClient.apiClient(),
organizationId: process.env.ORGANIZATION_ID!,
signWith: process.env.EOA_ADDRESS as `0x${string}`,
});
const paymasterAccount = await createAccount({
client: turnkeyClient.apiClient(),
organizationId: process.env.ORGANIZATION_ID!,
signWith: process.env.PAYMASTER_ADDRESS as `0x${string}`,
});
const userWalletClient = createWalletClient({
account: userAccount,
chain: base,
transport: http(process.env.BASE_RPC_URL!),
});
const paymasterWalletClient = createWalletClient({
account: paymasterAccount,
chain: base,
transport: http(process.env.BASE_RPC_URL!),
});
const userClient = new GasStationClient({
walletClient: userWalletClient,
});
const paymasterClient = new GasStationClient({
walletClient: paymasterWalletClient,
});
const authorization = await userClient.signAuthorization();
await paymasterClient.submitAuthorizations([authorization]);
let nonce = await userClient.getNonce();
const ethIntent = await userClient
.createIntent()
.transferETH("0xRecipient...", parseEther("0.1"))
.sign(nonce);
await paymasterClient.execute(ethIntent);
nonce = await userClient.getNonce();
const usdcIntent = await userClient
.createIntent()
.transferToken(
"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"0xRecipient...",
parseUnits("10", 6),
)
.sign(nonce);
await paymasterClient.execute(usdcIntent);
Core API
GasStationClient
Main client for gas station operations. Each client instance wraps a viem wallet client.
Constructor
new GasStationClient({
walletClient: WalletClient,
delegateContract?: `0x${string}`,
executionContract?: `0x${string}`,
})
Methods
End User Methods (call with user client):
signAuthorization(): Promise<SignedAuthorization>
- Sign an EIP-7702 authorization for the gas station contract
- Returns authorization that can be submitted by paymaster
createIntent(): IntentBuilder
- Create a builder for composing transactions
- Intent must be signed before execution
getNonce(address?: Address): Promise<bigint>
- Get current nonce from gas station contract
- Defaults to the signer's address if not specified
Paymaster Methods (call with paymaster client):
submitAuthorizations(authorizations: SignedAuthorization[]): Promise<{ txHash, blockNumber }>
- Submit signed EIP-7702 authorization transaction(s)
- Supports authorizing multiple EOAs in a single transaction
- Paymaster pays for gas
execute(intent: ExecutionIntent): Promise<{ txHash, blockNumber, gasUsed }>
- Execute a signed intent through the gas station
- Paymaster pays for gas
IntentBuilder
Composable builder for complex multi-step transactions.
const nonce = await userClient.getNonce();
const builder = userClient.createIntent();
const intent = await builder
.transferToken(usdcAddress, recipient, amount)
.sign(nonce);
await paymasterClient.execute(intent);
Common Use Cases
Simple Payment
const nonce = await userClient.getNonce();
const intent = await userClient
.createIntent()
.transferToken(usdcAddress, recipientAddress, parseUnits("50", 6))
.sign(nonce);
const result = await paymasterClient.execute(intent);
console.log(`Payment sent: ${result.txHash}`);
Token Approval + DEX Swap
let nonce = await userClient.getNonce();
const approvalIntent = await userClient
.createIntent()
.approveToken(usdcAddress, dexAddress, parseUnits("100", 6))
.sign(nonce);
await paymasterClient.execute(approvalIntent);
nonce = await userClient.getNonce();
const swapIntent = await userClient
.createIntent()
.callContract({
contract: dexAddress,
abi: DEX_ABI,
functionName: "swapExactTokensForTokens",
args: [amountIn, amountOutMin, path, recipient, deadline],
})
.sign(nonce);
await paymasterClient.execute(swapIntent);
User Onboarding
async function onboardUser(userAddress: string) {
const userAccount = await createAccount({
client: turnkeyClient.apiClient(),
organizationId: ORGANIZATION_ID,
signWith: userAddress as `0x${string}`,
});
const userWalletClient = createWalletClient({
account: userAccount,
chain: base,
transport: http(BASE_RPC_URL),
});
const userClient = new GasStationClient({
walletClient: userWalletClient,
});
const authorization = await userClient.signAuthorization();
await paymasterClient.submitAuthorizations([authorization]);
console.log("✅ User ready for gasless transactions!");
}
Architecture
Gas Station Pattern
- Delegate Contract: Authorized to EOA via EIP-7702
- Execution Contract: Contains execution logic and nonce management
- EOA: Signs EIP-712 intents off-chain
- Paymaster: Submits transactions and pays gas
Transaction Flow
User (EOA)
↓ Signs EIP-712 intent off-chain
↓
SDK (GasStationClient)
↓ Builds transaction
↓
Paymaster
↓ Submits transaction, pays gas
↓
Gas Station Contract
↓ Validates signature & nonce
↓ Executes on behalf of EOA
↓
Target Contract (USDC, NFT, DEX, etc.)
Chain Support
Available presets for quick setup:
- BASE_MAINNET - Base mainnet (includes USDC address)
- ETHEREUM_MAINNET - Ethereum mainnet (includes USDC address)
import { CHAIN_PRESETS, GasStationClient } from "@turnkey/gas-station";
import { createWalletClient, http } from "viem";
const basePreset = CHAIN_PRESETS.BASE_MAINNET;
const userWalletClient = createWalletClient({
account: userAccount,
chain: basePreset.chain,
transport: http(basePreset.rpcUrl),
});
const userClient = new GasStationClient({
walletClient: userWalletClient,
});
Security
- EIP-712 Signed Intents: All executions require valid typed signatures
- EIP-7702 Scoping: Authorization is per-EOA and can be revoked
- Deadline Enforcement: Each transaction includes a deadline (Unix timestamp) to prevent replay attacks; signatures expire after this time
- Turnkey Integration: Private keys never leave Turnkey's secure infrastructure
Security Policies
Turnkey policies provide additional security layers by restricting what transactions can be signed and executed. The Gas Station SDK includes helpers for creating these policies.
EOA Intent Signing Policies
Restrict what EIP-712 intents the EOA can sign:
import { buildIntentSigningPolicy } from "@turnkey/gas-station";
const eoaPolicy = buildIntentSigningPolicy({
organizationId: "your-org-id",
eoaUserId: "user-id",
restrictions: {
allowedContracts: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
disallowEthTransfer: true,
},
policyName: "USDC Only Policy",
});
Paymaster Execution Policies
Restrict what on-chain transactions the paymaster can submit:
import {
buildPaymasterExecutionPolicy,
DEFAULT_EXECUTION_CONTRACT,
ensureGasStationInterface,
} from "@turnkey/gas-station";
import { parseGwei, parseEther } from "viem";
await ensureGasStationInterface(
turnkeyClient.apiClient(),
"your-org-id",
DEFAULT_EXECUTION_CONTRACT,
undefined,
"Base Mainnet",
);
const paymasterPolicy = buildPaymasterExecutionPolicy({
organizationId: "paymaster-org-id",
paymasterUserId: "paymaster-user-id",
executionContractAddress: DEFAULT_EXECUTION_CONTRACT,
restrictions: {
allowedEOAs: ["0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"],
allowedContracts: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
maxEthAmount: parseEther("0.1"),
maxGasPrice: parseGwei("50"),
maxGasLimit: 500000n,
},
policyName: "Paymaster Protection",
});
Note: The ensureGasStationInterface()
function uploads the Gas Station ABI to Turnkey's Smart Contract Interface feature. This enables Turnkey's policy engine to parse the ABI-encoded transaction data and directly compare the ethAmount
parameter as a uint256 value, rather than raw bytes. The function checks if the ABI already exists before uploading to avoid duplicates.
Defense in Depth
Combine both policy types for maximum security:
import {
buildIntentSigningPolicy,
buildPaymasterExecutionPolicy,
DEFAULT_EXECUTION_CONTRACT,
} from "@turnkey/gas-station";
import { parseGwei } from "viem";
const eoaPolicy = buildIntentSigningPolicy({
organizationId: "user-org",
eoaUserId: "user-id",
restrictions: {
allowedContracts: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
disallowEthTransfer: true,
},
});
const paymasterPolicy = buildPaymasterExecutionPolicy({
organizationId: "paymaster-org",
paymasterUserId: "paymaster-user-id",
executionContractAddress: DEFAULT_EXECUTION_CONTRACT,
restrictions: {
allowedEOAs: ["0xUserAddress..."],
allowedContracts: ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"],
maxGasPrice: parseGwei("50"),
maxGasLimit: 500000n,
},
});
Advanced: Writing Custom Paymaster Policies
When using buildPaymasterExecutionPolicy
, the SDK creates Turnkey policies that parse the transaction calldata to enforce restrictions. Understanding the transaction structure allows you to write custom policies for advanced use cases.
Transaction Data Structure
When the paymaster signs an execution transaction calling execute(address _target, address _to, uint256 _ethAmount, bytes _data)
, the transaction data (eth.tx.data
) has the following structure:
[2..10] | 8 chars | Function selector | 6c5c2ed9 (execute) |
[10..74] | 64 chars | _target (EOA, padded) | 0000...742d35cc6634c0532925a3b844bc9e7595f0beb |
[74..138] | 64 chars | _to (output contract) | 0000...833589fcd6edb6e08f4c7c32d4f71b54bda02913 |
[138..202] | 64 chars | _ethAmount (uint256) | 0000...0000 (0 ETH) or amount in wei |
[202..266] | 64 chars | Offset to _data bytes | 0000...0080 (128 bytes) |
[266..330] | 64 chars | Packed data length | 0000...0055 (85 bytes: 65+16+4) |
[330..460] | 130 chars | Signature (65 bytes) | EIP-712 signature from EOA |
[460..492] | 32 chars | Nonce (16 bytes) | 00000000000000000000000000000000 |
[492..500] | 8 chars | Deadline (4 bytes) | 6ac7d340 (Unix timestamp) |
[500+] | Variable | Call data | Encoded function call for target contract |
Important: Turnkey's eth.tx.data
includes the 0x
prefix, so positions start at index 2 (after 0x
).
Note: The deadline is a Unix timestamp that prevents replay attacks by expiring signatures after a specified time. The SDK defaults to 1 hour, customizable with withDeadline()
.
Policy Conditions Reference
Check execution contract address:
eth.tx.to == "0x576a4d741b96996cc93b4919a04c16545734481f";
Check which EOA is executing:
eth.tx.data[10..74] == '0000000000000000000000742d35cc6634c0532925a3b844bc9e7595f0beb'
Check target contract (output contract):
eth.tx.data[74..138] == '0000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913'
Check ETH amount:
eth.tx.data[138..202].hex_to_uint() <= 100000000000000000;
Check gas price:
eth.tx.gasPrice <= 50000000000;
Check gas limit:
eth.tx.gas <= 500000;
Example: Custom Multi-Contract Policy
Allow paymaster to execute for USDC or DAI only:
const policy = {
organizationId: "paymaster-org-id",
policyName: "Stablecoin Execution Policy",
effect: "EFFECT_ALLOW",
consensus: `approvers.any(user, user.id == '${paymasterUserId}')`,
condition: [
"activity.resource == 'PRIVATE_KEY'",
"activity.action == 'SIGN'",
"eth.tx.to == '0x576a4d741b96996cc93b4919a04c16545734481f'",
"(eth.tx.data[74..138] == '0000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913' || eth.tx.data[74..138] == '00000000000000000000006b175474e89094c44da98b954eedeac495271d0f')",
"eth.tx.gasPrice <= 100000000000",
"eth.tx.gas <= 500000",
].join(" && "),
notes: "Allow USDC and DAI execution with gas limits",
};
await turnkeyClient.apiClient().createPolicy(policy);
Example: Whitelist Specific EOAs
Only allow execution for approved user wallets:
const approvedEOAs = [
"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
"0x1234567890123456789012345678901234567890",
];
const eoaConditions = approvedEOAs
.map((addr) => {
const padded = addr.slice(2).toLowerCase().padStart(64, "0");
return `eth.tx.data[10..74] == '${padded}'`;
})
.join(" || ");
const policy = {
organizationId: "paymaster-org-id",
policyName: "Approved Users Only",
effect: "EFFECT_ALLOW",
consensus: `approvers.any(user, user.id == '${paymasterUserId}')`,
condition: [
"activity.resource == 'PRIVATE_KEY'",
"activity.action == 'SIGN'",
"eth.tx.to == '0x576a4d741b96996cc93b4919a04c16545734481f'",
`(${eoaConditions})`,
].join(" && "),
};
await turnkeyClient.apiClient().createPolicy(policy);
Using the Helper Functions
For most cases, use the built-in helpers which handle the byte positions correctly:
import {
buildPaymasterExecutionPolicy,
DEFAULT_EXECUTION_CONTRACT,
} from "@turnkey/gas-station";
import { parseGwei } from "viem";
const policy = buildPaymasterExecutionPolicy({
organizationId: subOrgId,
paymasterUserId: paymasterUserId,
executionContractAddress: DEFAULT_EXECUTION_CONTRACT,
restrictions: {
allowedContracts: [
"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
],
allowedEOAs: ["0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"],
maxGasPrice: parseGwei("100"),
maxGasLimit: 500000n,
},
policyName: "Production Paymaster Policy",
});
await turnkeyClient.apiClient().createPolicy(policy);
The helper functions automatically:
- Convert addresses to lowercase
- Add proper padding for EOA addresses
- Calculate correct byte positions (accounting for
0x
prefix)
- Generate proper OR conditions for multiple allowed values
Troubleshooting
Authorization Failed
- Ensure paymaster has ETH for gas
- Verify delegate contract address is correct
Execution Failed
- Confirm EOA is authorized (check with
isAuthorized()
)
- Verify execution contract address matches deployment
- Check nonce hasn't been reused
- Ensure target contract call is valid
Insufficient Funds
- EOA must have sufficient token balance for transfers
- Paymaster must have ETH for gas
Invalid Signature
- Verify EOA address is correct
- Ensure chain ID matches the network
- Check intent was signed with correct nonce
Best Practices
- Client Separation: Create separate client instances for users and paymasters
- Authorization: Only call
authorize()
once per EOA
- Nonce Management: Always fetch fresh nonce before creating intents
- Rate Limiting: Implement paymaster rate limits to prevent abuse
License
See the main SDK repository for license information.