Skip to content

Instantly share code, notes, and snippets.

@agustinustheo
Created April 24, 2025 05:48
Show Gist options
  • Save agustinustheo/26b9342746d58937b054986240da4217 to your computer and use it in GitHub Desktop.
Save agustinustheo/26b9342746d58937b054986240da4217 to your computer and use it in GitHub Desktop.
import * as dotenv from 'dotenv';
import axios from 'axios';
import { ethers } from 'ethers';
import { Hex, hashMessage } from 'viem';
dotenv.config();
const PARTICLE_API_URL = 'https://rpc.particle.network';
const CHAIN_ID = 42161; // Arbitrum mainnet
const RPC_URL = 'https://arb1.arbitrum.io/rpc';
const rpcUrl = () => PARTICLE_API_URL;
const payloadId = () => Math.floor(Math.random() * Math.pow(10, 3));
// Define type interfaces needed for SmartAccount
interface IEthereumProvider {
request(args: RequestArguments): Promise<any>;
on?(eventName: string, listener: (...args: any[]) => void): any;
}
interface PasskeyProvider {
isPasskey: boolean;
getPasskeyOption?: () => Promise<any>;
}
interface RequestArguments {
method: string;
params?: any[];
}
interface AccountContract {
name: string;
version: string;
}
interface SmartAccountConfig {
projectId: string;
clientKey: string;
appId: string;
aaOptions: {
accountContracts: {
[key: string]: {
version: string;
chainIds?: number[];
}[];
};
};
}
interface Account {
smartAccountAddress: string;
isDeployed: boolean;
}
interface AccountConfig {
name: string;
version: string;
ownerAddress: string;
options?: {
passkeyOption?: any;
};
}
interface Transaction {
to: string;
data: string;
value?: string;
}
interface FeeQuotesResponse {
verificationGasLimit?: string;
preVerificationGas?: string;
maxFeePerGas?: string;
maxPriorityFeePerGas?: string;
paymasterAndData?: string;
callGasLimit?: string;
}
interface UserOp {
sender: string;
nonce: string;
initCode: string;
callData: string;
callGasLimit: string;
verificationGasLimit: string;
preVerificationGas: string;
maxFeePerGas: string;
maxPriorityFeePerGas: string;
paymasterAndData: string;
signature: string;
}
interface UserOpBundle {
userOp: UserOp;
userOpHash: string;
}
interface UserOpParams {
tx: Transaction | Transaction[];
feeQuote?: FeeQuotesResponse;
tokenPaymasterAddress?: string;
}
type SendTransactionParams = UserOpBundle | UserOpParams;
interface SessionKey {
// Simplified type - can be extended if needed
[key: string]: any;
}
interface CreateSessionKeyOptions {
// Simplified type - can be extended if needed
[key: string]: any;
}
interface SessionKeySignerParams {
// Simplified type - can be extended if needed
[key: string]: any;
}
// Implement SmartAccount class locally
class SmartAccount {
private connection;
private smartAccountContract: AccountContract;
constructor(
public provider: IEthereumProvider & Partial<PasskeyProvider>,
private config: SmartAccountConfig,
) {
if (
!this.config.projectId ||
!this.config.clientKey ||
!this.config.appId
) {
throw new Error('invalid project config');
}
if (!this.config.aaOptions.accountContracts) {
throw new Error('invalid AA contract config');
}
const name = Object.keys(this.config.aaOptions.accountContracts)[0];
const version = this.config.aaOptions.accountContracts[name]?.[0]?.version;
if (!name || !version) {
throw new Error('invalid AA name or version');
}
this.smartAccountContract = {
name,
version,
};
this.connection = axios.create({
baseURL: `${rpcUrl()}/evm-chain`,
timeout: 60_000,
});
this.connection.interceptors.request.use((config) => {
if (config?.data?.method) {
config.baseURL = `${config.baseURL}${config.baseURL?.includes('?') ? '&' : '?'}method=${
config?.data?.method
}`;
}
return config;
});
}
setSmartAccountContract(contract: AccountContract) {
const accountContract =
this.config.aaOptions.accountContracts[contract.name];
if (
!accountContract ||
accountContract.length === 0 ||
accountContract.every((item) => item.version !== contract.version)
) {
throw new Error('Please configure the smart account contract first');
}
this.smartAccountContract = contract;
}
getChainId = async (): Promise<string> => {
return await this.provider.request({ method: 'eth_chainId' });
};
getOwner = async (): Promise<string> => {
const eoas = await this.provider.request({ method: 'eth_accounts' });
return eoas[0];
};
signUserOpHash = async (userOpHash: Hex): Promise<string> => {
let message = userOpHash;
if (
this.provider.isPasskey &&
this.smartAccountContract.name !== 'COINBASE'
) {
message = hashMessage({
raw: userOpHash,
});
}
const eoa = await this.getOwner();
const signature = await this.provider.request({
method: 'personal_sign',
params: [message, eoa],
});
return signature;
};
private async getAccountConfig(): Promise<AccountConfig> {
const accountContract =
this.config.aaOptions.accountContracts[this.smartAccountContract.name];
if (
!accountContract ||
accountContract.every(
(item) => item.version !== this.smartAccountContract.version,
)
) {
throw new Error('Please configure the smart account contract first');
}
const ownerAddress = await this.getOwner();
let passkeyOption;
if (this.provider.isPasskey) {
passkeyOption = await this.provider.getPasskeyOption?.();
}
return {
name: this.smartAccountContract.name,
version: this.smartAccountContract.version,
ownerAddress,
options: passkeyOption
? {
passkeyOption,
}
: undefined,
};
}
async getFeeQuotes(
tx: Transaction | Transaction[],
): Promise<FeeQuotesResponse> {
const accountConfig = await this.getAccountConfig();
return this.sendRpc<FeeQuotesResponse>({
method: 'particle_aa_getFeeQuotes',
params: [accountConfig, Array.isArray(tx) ? tx : [tx]],
});
}
async buildUserOperation({
tx,
feeQuote,
tokenPaymasterAddress,
}: UserOpParams): Promise<UserOpBundle> {
const accountConfig = await this.getAccountConfig();
return await this.sendRpc<UserOpBundle>({
method: 'particle_aa_createUserOp',
params: [
accountConfig,
Array.isArray(tx) ? tx : [tx],
feeQuote,
tokenPaymasterAddress,
].filter((val) => !!val),
});
}
async signUserOperation({
userOpHash,
userOp,
}: UserOpBundle): Promise<UserOp> {
const signature = await this.signUserOpHash(userOpHash as Hex);
return { ...userOp, signature };
}
async sendUserOperation({
userOpHash,
userOp,
}: UserOpBundle): Promise<string> {
const signedUserOp = await this.signUserOperation({ userOpHash, userOp });
return this.sendSignedUserOperation(signedUserOp);
}
async sendSignedUserOperation(
userOp: UserOp,
signerParams?: SessionKeySignerParams,
): Promise<string> {
const accountConfig = await this.getAccountConfig();
return this.sendRpc<string>({
method: 'particle_aa_sendUserOp',
params: [accountConfig, userOp, signerParams],
});
}
async sendTransaction(params: SendTransactionParams): Promise<string> {
if (
Object.prototype.hasOwnProperty.call(params, 'userOpHash') &&
Object.prototype.hasOwnProperty.call(params, 'userOp')
) {
const { userOpHash, userOp } = params as UserOpBundle;
if (userOpHash && userOp) {
return this.sendUserOperation({ userOpHash, userOp });
}
}
const { tx, feeQuote, tokenPaymasterAddress } = params as UserOpParams;
const userOpBundle = await this.buildUserOperation({
tx,
feeQuote,
tokenPaymasterAddress,
});
return this.sendUserOperation(userOpBundle);
}
async getAccount(): Promise<Account> {
const accountConfig = await this.getAccountConfig();
const accounts = await this.sendRpc<Account[]>({
method: 'particle_aa_getSmartAccount',
params: [accountConfig],
});
return accounts[0];
}
async getAddress(): Promise<string> {
let suffix = await this.getOwner();
if (!suffix) {
return '';
}
if (
this.provider.isPasskey &&
suffix === '0x0000000000000000000000000000000000000000'
) {
// passkey
const credentialId = (await this.provider.getPasskeyOption?.())
?.credentialId;
if (credentialId) {
suffix = credentialId;
}
}
const accountConfig = await this.getAccountConfig();
const localKey = `particle_${accountConfig.name}_${accountConfig.version}_${suffix}`;
if (typeof window !== 'undefined' && localStorage) {
const localAA = localStorage.getItem(localKey);
if (localAA) {
return localAA;
}
}
const configKey = JSON.stringify(accountConfig);
let accountPromise = loadAccountPromise.get(configKey);
if (!accountPromise) {
accountPromise = this.getAccount();
loadAccountPromise.set(configKey, accountPromise);
}
try {
const account = await accountPromise;
const address = account.smartAccountAddress;
if (typeof window !== 'undefined' && localStorage) {
localStorage.setItem(localKey, address);
}
return address;
} catch (error) {
loadAccountPromise.delete(configKey);
throw error;
}
}
async isDeployed(): Promise<boolean> {
const account = await this.getAccount();
return account.isDeployed;
}
async deployWalletContract(): Promise<string> {
return this.sendTransaction({
tx: {
to: '0x0000000000000000000000000000000000000000',
data: '0x',
},
});
}
async sendRpc<T>(arg: RequestArguments): Promise<T> {
const chainId = Number(await this.getChainId());
const accountContract =
this.config.aaOptions.accountContracts[this.smartAccountContract.name];
const contractConfig = accountContract.find(
(contract) => contract.version === this.smartAccountContract.version,
);
if (contractConfig?.chainIds?.length) {
if (!contractConfig.chainIds.includes(chainId)) {
throw new Error(`Invalid Chain: ${chainId}`);
}
}
const response = await this.connection
.post(
'',
{
...arg,
id: payloadId(),
jsonrpc: '2.0',
},
{
params: {
chainId,
projectUuid: this.config.projectId,
projectKey: this.config.clientKey,
},
},
)
.then((res) => res.data);
if (response.error) {
return Promise.reject(response.error);
} else {
return response.result;
}
}
async createSessions(
options: CreateSessionKeyOptions[],
): Promise<FeeQuotesResponse> {
const accountConfig = await this.getAccountConfig();
return await this.sendRpc<FeeQuotesResponse>({
method: 'particle_aa_createSessions',
params: [accountConfig, options],
});
}
async validateSession(
targetSession: SessionKey,
sessions: SessionKey[],
): Promise<boolean> {
const accountConfig = await this.getAccountConfig();
return await this.sendRpc<boolean>({
method: 'particle_aa_validateSession',
params: [
accountConfig,
{
sessions,
targetSession: targetSession,
},
],
});
}
}
const loadAccountPromise = new Map<string, Promise<Account>>();
// Initialize wallet, smart account and get user addresses
async function initializeWalletAndSmartAccount() {
const privateKey = process.env.PRIVATE_KEY;
const projectId = process.env.PARTICLE_PROJECT_ID;
const clientKey = process.env.PARTICLE_SERVER_KEY;
const appId = process.env.PARTICLE_APP_ID;
if (!privateKey) {
throw new Error(
'PRIVATE_KEY not found in environment variables. Add it to your .env file',
);
}
if (!projectId || !clientKey || !appId) {
throw new Error(
'PARTICLE_PROJECT_ID, PARTICLE_SERVER_KEY, or PARTICLE_APP_ID not found in environment variables. Add them to your .env file',
);
}
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(privateKey, provider);
const eip1193Provider = {
request: async ({ method, params }: { method: string; params: any[] }) => {
if (method === 'eth_accounts' || method === 'eth_requestAccounts') {
return [wallet.address];
}
if (method === 'personal_sign') {
const messageHex = params[0];
const address = params[1];
// Verify the address matches
if (address.toLowerCase() !== wallet.address.toLowerCase()) {
throw new Error('Address mismatch');
}
// Sign the message with the wallet's private key
const signature = await wallet.signMessage(ethers.getBytes(messageHex));
return signature;
}
return provider.send(method, params || []);
},
on: (eventName: string, listener: any) => {
if (eventName === 'accountsChanged') {
setTimeout(() => listener([wallet.address]), 0);
}
return eip1193Provider;
},
};
const smartAccount = new SmartAccount(eip1193Provider, {
projectId,
clientKey,
appId,
aaOptions: {
accountContracts: {
BICONOMY: [
{
version: '2.0.0',
chainIds: [CHAIN_ID],
},
],
},
},
});
smartAccount.setSmartAccountContract({ name: 'BICONOMY', version: '2.0.0' });
const eoaAddress = wallet.address;
const smartAccountAddress = await smartAccount.getAddress();
console.log('EOA Address:', eoaAddress);
console.log('Smart Account Address:', smartAccountAddress);
return { provider, wallet, smartAccount, eoaAddress, smartAccountAddress };
}
// Parse command line arguments
function parseArgs() {
const args = process.argv.slice(2);
const swapExactAmountOut = args.includes('--swapexactamountout');
const swapExactAmountIn =
args.includes('--swapexactamountin') || !swapExactAmountOut;
return {
swapExactAmountIn,
swapExactAmountOut,
};
}
function getParticleHeaders() {
const projectId = process.env.PARTICLE_PROJECT_ID;
const serverKey = process.env.PARTICLE_SERVER_KEY;
if (!projectId || !serverKey) {
throw new Error(
'PARTICLE_PROJECT_ID or PARTICLE_SERVER_KEY not found in environment variables. Add them to your .env file',
);
}
const auth = Buffer.from(`${projectId}:${serverKey}`).toString('base64');
return {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
};
}
async function particleRpcCall(
method: string,
params: any[],
chainId = CHAIN_ID,
) {
try {
const response = await axios.post(
`${PARTICLE_API_URL}/evm-chain`,
{
jsonrpc: '2.0',
method,
params,
id: 1, // Added ID parameter for JSON-RPC
chainId,
},
{ headers: getParticleHeaders() },
);
if (response.data.error) {
throw new Error(
`Particle API Error: ${JSON.stringify(response.data.error)}`,
);
}
return response.data.result;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('API Response:', error.response?.data);
throw new Error(`Network Error: ${error.message}`);
}
throw error;
}
}
async function checkApprove(
userAddress: string,
token: { tokenAddress: string; amount: string },
chainId = CHAIN_ID,
) {
console.log('Checking token approval status...');
// Native tokens don't need approval
if (
token.tokenAddress.toLowerCase() ===
'0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
) {
console.log('Token is native, no approval needed');
return { approved: true };
}
const result = await particleRpcCall(
'particle_swap_checkApprove',
[userAddress, token],
chainId,
);
console.log(
`Approval status: ${result.approved ? 'Approved' : 'Not approved'}`,
);
return result;
}
async function executeApprovalTransaction(tx: any, smartAccount: SmartAccount) {
console.log('Executing approval transaction using Smart Account...');
try {
const txParams = {
tx: {
to: tx.to,
data: tx.data,
value: tx.value || '0x0',
},
};
console.log('Building UserOp for approval transaction...');
const userOpBundle = await smartAccount.buildUserOperation(txParams);
console.log('Sending UserOp for approval transaction...');
const txHash = await smartAccount.sendUserOperation({
userOp: userOpBundle.userOp,
userOpHash: userOpBundle.userOpHash,
});
console.log(`Approval transaction sent! Hash: ${txHash}`);
return { hash: txHash };
} catch (error) {
console.error('Failed to send approval transaction:', error);
throw new Error(
`Approval transaction failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function getQuote(
userAddress: string,
fromToken: { tokenAddress: string; amount: string },
toToken: { tokenAddress: string; amount?: string },
swapMethod: string = 'exactAmountIn',
chainId = CHAIN_ID,
) {
console.log(`Getting swap quote for ${swapMethod}...`);
try {
const params: any = {
fromTokenAddress: fromToken.tokenAddress,
toTokenAddress: toToken.tokenAddress,
slippage: 1, // 1% slippage as a number, not a string
};
// Set amount based on swap method
if (swapMethod === 'exactAmountIn') {
params.amount = fromToken.amount;
} else if (swapMethod === 'exactAmountOut') {
params.amount = toToken.amount;
params.exactOut = true;
}
const result = await particleRpcCall(
'particle_swap_getQuote',
[userAddress, params],
chainId,
);
console.log(
`Quote received: ${fromToken.amount} -> ${result.toTokenAmount}`,
);
return result;
} catch (error) {
console.error(`Error getting quote: ${error.message}`);
throw error;
}
}
async function getSwap(
userAddress: string,
fromToken: { tokenAddress: string; amount: string },
toToken: { tokenAddress: string; amount?: string },
swapMethod: string = 'exactAmountIn',
chainId = CHAIN_ID,
) {
console.log(`Generating swap transaction for ${swapMethod}...`);
const params: any = {
fromTokenAddress: fromToken.tokenAddress,
toTokenAddress: toToken.tokenAddress,
slippage: 1, // 1% slippage as a number, not a string
};
// Set amount based on swap method
if (swapMethod === 'exactAmountIn') {
params.amount = fromToken.amount;
} else if (swapMethod === 'exactAmountOut') {
params.amount = toToken.amount;
params.exactOut = true;
}
const result = await particleRpcCall(
'particle_swap_getSwap',
[userAddress, params],
chainId,
);
console.log('Swap transaction generated');
return result;
}
async function executeSwapTransaction(tx: any, smartAccount: SmartAccount) {
console.log('Executing swap transaction using Smart Account...');
try {
const txParams = {
tx: {
to: tx.to,
data: tx.data,
value: tx.value || '0x0',
},
};
console.log('Building UserOp for swap transaction...');
const userOpBundle = await smartAccount.buildUserOperation(txParams);
console.log('Sending UserOp for swap transaction...');
const txHash = await smartAccount.sendUserOperation({
userOp: userOpBundle.userOp,
userOpHash: userOpBundle.userOpHash,
});
console.log(`Swap transaction sent! Hash: ${txHash}`);
return {
status: 'TRANSACTION_COMPLETE',
hash: txHash,
};
} catch (error) {
console.error('Failed to send swap transaction:', error);
throw new Error(
`Swap transaction failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function ensureWalletDeployed(smartAccount: SmartAccount) {
console.log('Checking if smart account is deployed...');
const isDeployed = await smartAccount.isDeployed();
if (!isDeployed) {
console.log('Smart account not deployed. Deploying now...');
const txHash = await smartAccount.deployWalletContract();
console.log(`Smart account deployed! Hash: ${txHash}`);
return txHash;
}
console.log('Smart account already deployed');
return null;
}
async function executeSwap(
config: {
userAddress: string;
fromToken: { tokenAddress: string; amount: string };
toToken: { tokenAddress: string; amount?: string };
swapMethod: string;
},
smartAccount: SmartAccount,
chainId = CHAIN_ID,
) {
try {
const { userAddress, fromToken, toToken, swapMethod } = config;
if (!userAddress) {
throw new Error('User wallet address not available');
}
console.log('Starting swap process...');
console.log(`Chain ID: ${chainId}`);
console.log(`Swap Method: ${swapMethod}`);
console.log(`From: ${fromToken.tokenAddress}`);
console.log(`To: ${toToken.tokenAddress}`);
if (swapMethod === 'exactAmountIn') {
console.log(`Input Amount: ${fromToken.amount}`);
} else if (swapMethod === 'exactAmountOut') {
console.log(`Output Amount: ${toToken.amount}`);
}
console.log(`User address: ${userAddress}`);
// Step 1: Check if token approval is needed
const approvalStatus = await checkApprove(userAddress, fromToken, chainId);
// Step 2: If approval is needed, execute approval transaction first
if (!approvalStatus.approved && approvalStatus.tx) {
console.log(
'\nToken approval required. Executing approval transaction...',
);
await executeApprovalTransaction(approvalStatus.tx, smartAccount);
console.log('Approval complete. Continuing with swap...');
}
// Step 3: Get swap quote
const swapQuote = await getQuote(
userAddress,
fromToken,
toToken,
swapMethod,
chainId,
);
if (swapMethod === 'exactAmountIn') {
console.log(`\nExpected output amount: ${swapQuote.toTokenAmount}`);
} else if (swapMethod === 'exactAmountOut') {
console.log(`\nRequired input amount: ${swapQuote.fromTokenAmount}`);
fromToken.amount = swapQuote.fromTokenAmount;
}
// Step 4: Get swap transaction
const swapData = await getSwap(
userAddress,
fromToken,
toToken,
swapMethod,
chainId,
);
// Step 5: Execute the swap
const result = await executeSwapTransaction(swapData.tx, smartAccount);
console.log('\nSwap completed successfully!');
return result;
} catch (error) {
console.error('Error executing swap:', error);
throw error;
}
}
// Main function to handle the entire process
async function main() {
// Initialize wallet and smart account
const { wallet, smartAccount } = await initializeWalletAndSmartAccount();
const userAddress = wallet.address;
// Get command line arguments
const args = parseArgs();
console.log(
`Running in ${args.swapExactAmountOut ? 'exact amount out' : 'exact amount in'} mode`,
);
// Create swap configuration based on command line args
const swapConfig = {
userAddress,
fromToken: {
tokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', // Native token (ETH)
amount: '100000000000000', // 0.0001 ETH in wei
},
toToken: {
tokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC
amount: '1000000', // 1 USDC (6 decimals)
},
swapMethod: args.swapExactAmountOut ? 'exactAmountOut' : 'exactAmountIn',
};
// Execute the swap with the configuration
return executeSwap(swapConfig, smartAccount);
}
// Entry point when run directly
if (require.main === module) {
main()
.then(() => process.exit(0))
.catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}
// Create default swap config for exports
const DEFAULT_SWAP_CONFIG = {
userAddress: '', // Will be filled in by whoever imports
fromToken: {
tokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', // Native token (ETH)
amount: '100000000000000', // 0.0001 ETH in wei
},
toToken: {
tokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC
amount: '1000000', // 1 USDC (6 decimals)
},
swapMethod: 'exactAmountIn',
};
export {
checkApprove,
getQuote,
getSwap,
executeSwapTransaction,
executeApprovalTransaction,
executeSwap,
initializeWalletAndSmartAccount,
main,
DEFAULT_SWAP_CONFIG,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment