diff --git a/.env.local.template b/.env.local.template index bfaa3ca87..6d05b1d80 100644 --- a/.env.local.template +++ b/.env.local.template @@ -23,6 +23,8 @@ NEXT_PUBLIC_INTERCOM_IOS_API_KEY=ios_sdk-XXX ANVIL_BASE_FORK_URL=https://mainnet.base.org/ SECRET_SHOP_PRIVATE_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a SEND_ACCOUNT_FACTORY_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d +# Private key for funding account top-offs (bundler, paymasters, etc.) +FUNDING_TOPOFF_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE SNAPLET_HASH_KEY=sendapp SUPABASE_DB_URL=postgresql://postgres:postgres@localhost:54322/postgres SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long diff --git a/packages/workflows/jest.config.ts b/packages/workflows/jest.config.ts index fd870e05c..7e8d429a0 100644 --- a/packages/workflows/jest.config.ts +++ b/packages/workflows/jest.config.ts @@ -16,6 +16,9 @@ const config: Config = { coveragePathIgnorePatterns: ['/node_modules/'], coverageProvider: 'babel', workerThreads: true, + globals: { + __DEV__: true, + }, globalSetup: '/jest.setup.ts', transformIgnorePatterns: ['node_modules/(?!(get-port))'], moduleNameMapper: { diff --git a/packages/workflows/src/all-activities.ts b/packages/workflows/src/all-activities.ts index bada17f64..7d56605b2 100644 --- a/packages/workflows/src/all-activities.ts +++ b/packages/workflows/src/all-activities.ts @@ -3,16 +3,20 @@ import { erc7677BundlerClient } from './utils/erc7677-bundler-client' import { createTransferActivities } from './transfer-workflow/activities' import { createDepositActivities } from './deposit-workflow/activities' import { createUserOpActivities } from './userop-workflow/activities' +import { createTopoffActivities } from './topoff-workflow/activities' + export function createMonorepoActivities(env: Record) { return { ...createTransferActivities(env), ...createDepositActivities(env), // TODO: likely can remove since each workflow and activity should be self contained ...createUserOpActivities(env, sendBaseMainnetBundlerClient, erc7677BundlerClient), + ...createTopoffActivities(env), } } export { createTransferActivities } from './transfer-workflow/activities' export { createUserOpActivities } from './userop-workflow/activities' export { createDepositActivities } from './deposit-workflow/activities' +export { createTopoffActivities } from './topoff-workflow/activities' // export { createDistributionActivities } from './distribution-workflow/activities' diff --git a/packages/workflows/src/all-workflows.ts b/packages/workflows/src/all-workflows.ts index 53cecc33f..2f1ad00c6 100644 --- a/packages/workflows/src/all-workflows.ts +++ b/packages/workflows/src/all-workflows.ts @@ -1,3 +1,4 @@ export { DepositWorkflow } from './deposit-workflow/workflow' export { distributions } from './distribution-workflow/workflow' export { transfer } from './transfer-workflow/workflow' +export { topOffAccounts } from './topoff-workflow/workflow' diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index 1ff8ba192..0c70728e7 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -1,4 +1,5 @@ export { DepositWorkflow } from './deposit-workflow/workflow' export { distribution, distributions } from './distribution-workflow/workflow' export { transfer } from './transfer-workflow/workflow' +export { topOffAccounts } from './topoff-workflow/workflow' export { version } from './version' diff --git a/packages/workflows/src/topoff-workflow/README.md b/packages/workflows/src/topoff-workflow/README.md new file mode 100644 index 000000000..14640edf1 --- /dev/null +++ b/packages/workflows/src/topoff-workflow/README.md @@ -0,0 +1,209 @@ +# Account Top-Off Workflow + +Automated Temporal workflow for monitoring and topping off critical account balances on Base mainnet. + +## Overview + +This workflow runs every 15 minutes to check and automatically top off the following accounts: + +1. **Bundler** (0x9d1478044F781Ca722ff257e70D05e4Ad673f443) + - Type: ETH transfer + - Min threshold: 0.5 ETH + - Target balance: 2 ETH + +2. **Transaction Paymaster** (0x592e1224D203Be4214B15e205F6081FbbaCFcD2D) + - Type: Paymaster deposit (calls `deposit()`) + - Min threshold: 0.1 ETH + - Target balance: 1 ETH + +3. **Sponsored Paymaster** (0x8A77aE0c07047c5b307B2319A8F4Bd9d3604DdD8) + - Type: Paymaster deposit (calls `deposit()`) + - Min threshold: 0.1 ETH + - Target balance: 1 ETH + +4. **Preburn** (0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd) + - Type: USDC transfer + - Min threshold: 20 USDC + - Target balance: 100 USDC + + +## Environment Variables + +The workflow requires the following environment variables: + +```bash +# Required: Private key for funding account top-offs (should hold both ETH and USDC) +FUNDING_TOPOFF_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE + +# Required: Temporal connection +TEMPORAL_ADDR=localhost:7233 +``` + +**Important**: The funding wallet should hold both ETH and USDC. The workflow will check if sufficient funds are available before attempting top-offs. + +## Setup + +### 1. Configure Environment Variables + +Add the required environment variables to your `.env.local` file: + +```bash +FUNDING_TOPOFF_PRIVATE_KEY=0x... +``` + +### 2. Start Temporal Worker + +Make sure the Temporal worker is running to execute the workflows: + +```bash +# From the root of the monorepo +tilt up workers +``` + +Or manually: + +```bash +cd apps/workers +yarn start +``` + +### 3. Create the Schedule + +Run the schedule creation script once to set up the recurring workflow: + +```bash +# From the root of the monorepo +tsx packages/workflows/src/topoff-workflow/start-schedule.ts +``` + +This creates a Temporal schedule named `account-topoff-schedule` that runs every 15 minutes. + +## Usage + +### Check Schedule Status + +```bash +temporal schedule describe --schedule-id account-topoff-schedule +``` + +### Trigger Manual Run + +```bash +temporal workflow start \ + --type topOffAccounts \ + --task-queue monorepo@latest \ + --workflow-id manual-topoff-$(date +%s) +``` + +### Delete Schedule + +```bash +temporal schedule delete --schedule-id account-topoff-schedule +``` + +### Update Schedule + +To update the schedule (e.g., change frequency), delete it first and then recreate: + +```bash +temporal schedule delete --schedule-id account-topoff-schedule +tsx packages/workflows/src/topoff-workflow/start-schedule.ts +``` + +## Monitoring + +The workflow logs detailed information about each top-off operation: + +- Balance checks for each account +- Top-off transactions with amounts and transaction hashes +- Warnings for accounts below threshold +- Summary of all operations + +Enable debug logging to see detailed output: + +```bash +DEBUG=workflows:topoff tsx packages/workflows/src/topoff-workflow/start-schedule.ts +``` + +## Configuration + +Account configurations are defined in `config.ts`. Key configuration options: + +### Accounts + +To modify thresholds or add accounts: +1. Edit `packages/workflows/src/topoff-workflow/config.ts` +2. Update the `TOPOFF_ACCOUNTS` array +3. Rebuild the workflow bundle (happens automatically on next deployment) + +## Testing Locally + +To test the workflow without setting up a schedule: + +```bash +# Start local Temporal server (via Tilt) +tilt up + +# In another terminal, start the worker +cd apps/workers +yarn start + +# In another terminal, trigger the workflow manually +temporal workflow start \ + --type topOffAccounts \ + --task-queue monorepo@latest \ + --workflow-id test-topoff-$(date +%s) +``` + +## Deployment + +The workflow is deployed automatically with the rest of the monorepo workflows. After deployment: + +1. Ensure environment variables are set in production +2. Run the schedule creation script in production: + ```bash + tsx packages/workflows/src/topoff-workflow/start-schedule.ts + ``` + +## Architecture + +- **Workflow**: `workflow.ts` - Orchestrates the top-off process + 1. Calculates total ETH and USDC needed + 2. Checks if funding wallet has sufficient ETH and USDC + 3. Warns if insufficient funds (skips top-offs for that currency) + 4. Performs all top-offs sequentially for better observability + 5. Logs summary + +- **Activities**: `activities.ts` - Individual operations + - Balance checking: `checkEthBalance`, `checkFundingWalletEthBalance`, `checkPaymasterDeposit`, `checkUSDCBalance`, `checkUsdcBalanceOf` + - Top-offs: `sendEth`, `sendUsdc`, `depositToPaymaster`, `sendBundlerSelfTransaction` + - Helper: `calculateTotalETHNeeded`, `calculateTotalUSDCNeeded`, `checkAndTopOffAccount` + +- **Configuration**: `config.ts` - Account addresses and thresholds +- **Schedule**: `start-schedule.ts` - Creates the Temporal schedule + +## Error Handling + +**No Automatic Retries** - Since this is a cron job running every 15 minutes, all errors are **non-retryable**: + +- **Configuration errors** (missing env vars, unknown account types) → `ConfigurationError` +- **Transaction failures** (insufficient funds, RPC errors) → Propagated as-is + +**Why no retries?** +- Runs every 15 minutes automatically +- Transient issues (RPC down, API rate limits) will resolve by next run +- Avoids wasting gas on failed transactions +- Clearer logs - one attempt per run + +If an error occurs: +1. Activity fails immediately with `ApplicationFailure.nonRetryable()` +2. Error is logged clearly in Temporal UI +3. Next scheduled run (15 min later) will retry from scratch + +## Security + +- Private keys are stored in environment variables, never committed to git +- Use secure secret management in production (e.g., AWS Secrets Manager) +- The funding wallet should hold both ETH and USDC for top-offs +- Consider using a dedicated wallet with appropriate balance limits +- USDC transfers are sent directly without approval (standard ERC-20 transfers) diff --git a/packages/workflows/src/topoff-workflow/activities.test.ts b/packages/workflows/src/topoff-workflow/activities.test.ts new file mode 100644 index 000000000..046be497f --- /dev/null +++ b/packages/workflows/src/topoff-workflow/activities.test.ts @@ -0,0 +1,400 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import type { Address, Hex } from 'viem' +import { parseEther, parseUnits } from 'viem' +import { createTopoffActivities } from './activities' +import type { AccountConfig } from './config' + +// Mock dependencies +jest.mock('@my/wagmi', () => ({ + baseMainnetClient: { + getBalance: jest.fn(), + readContract: jest.fn(), + waitForTransactionReceipt: jest.fn(), + chain: { id: 8453 }, + }, +})) + +jest.mock('@my/wagmi/generated', () => ({ + sendVerifyingPaymasterAbi: [], + sendVerifyingPaymasterAddress: { 8453: '0x8A77aE0c07047c5b307B2319A8F4Bd9d3604DdD8' }, + tokenPaymasterAbi: [], + tokenPaymasterAddress: { 8453: '0x592e1224D203Be4214B15e205F6081FbbaCFcD2D' }, + usdcAbi: [], + usdcAddress: { 8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' }, +})) + +jest.mock('viem', () => { + const actual = jest.requireActual('viem') as object + return { + ...actual, + createWalletClient: jest.fn<() => { sendTransaction: jest.Mock; writeContract: jest.Mock }>(), + } +}) + +jest.mock('viem/accounts', () => ({ + privateKeyToAccount: jest.fn(() => ({ + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb' as Address, + })), +})) + +// Mock global fetch +global.fetch = jest.fn() as jest.MockedFunction + +// Import mocked modules +import { baseMainnetClient } from '@my/wagmi' +import { + tokenPaymasterAddress, + sendVerifyingPaymasterAddress, + usdcAddress, +} from '@my/wagmi/generated' +import { createWalletClient } from 'viem' + +const mockedBaseMainnetClient = baseMainnetClient as jest.Mocked +const mockedCreateWalletClient = createWalletClient as jest.MockedFunction< + typeof createWalletClient +> +const mockedFetch = global.fetch as jest.MockedFunction + +describe('Top-Off Workflow Activities', () => { + const mockFundingPrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + + let activities: ReturnType + + beforeEach(() => { + jest.clearAllMocks() + process.env.FUNDING_TOPOFF_PRIVATE_KEY = mockFundingPrivateKey + process.env.NEXT_PUBLIC_KYBER_SWAP_BASE_URL = 'https://aggregator-api.kyberswap.com' + process.env.NEXT_PUBLIC_KYBER_CLIENT_ID = 'test-client' + + activities = createTopoffActivities({ + FUNDING_TOPOFF_PRIVATE_KEY: mockFundingPrivateKey, + }) + }) + + describe('checkEthBalance', () => { + it('should return ETH balance for an address', async () => { + const mockAddress = '0x1234567890123456789012345678901234567890' as Address + const mockBalance = parseEther('1.5') + + mockedBaseMainnetClient.getBalance.mockResolvedValueOnce(mockBalance) + + const balance = await activities.checkEthBalance(mockAddress) + + expect(balance).toBe(mockBalance) + expect(mockedBaseMainnetClient.getBalance).toHaveBeenCalledWith({ address: mockAddress }) + }) + }) + + describe('checkPaymasterDeposit', () => { + it('should return deposit balance for TokenPaymaster', async () => { + const mockDeposit = parseEther('0.5') + + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(mockDeposit) + + const balance = await activities.checkPaymasterDeposit(tokenPaymasterAddress[8453]) + + expect(balance).toBe(mockDeposit) + expect(mockedBaseMainnetClient.readContract).toHaveBeenCalledWith({ + address: tokenPaymasterAddress[8453], + abi: expect.any(Array), + functionName: 'getDeposit', + }) + }) + + it('should return deposit balance for SendVerifyingPaymaster', async () => { + const mockDeposit = parseEther('0.3') + + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(mockDeposit) + + const balance = await activities.checkPaymasterDeposit(sendVerifyingPaymasterAddress[8453]) + + expect(balance).toBe(mockDeposit) + expect(mockedBaseMainnetClient.readContract).toHaveBeenCalledWith({ + address: sendVerifyingPaymasterAddress[8453], + abi: expect.any(Array), + functionName: 'getDeposit', + }) + }) + + it('should throw error for unknown paymaster address', async () => { + const unknownAddress = '0x0000000000000000000000000000000000000000' as Address + + await expect(activities.checkPaymasterDeposit(unknownAddress)).rejects.toThrow( + 'Unknown paymaster address' + ) + }) + }) + + describe('checkUSDCBalance', () => { + it('should return USDC balance for funding wallet', async () => { + const mockBalance = parseUnits('1000', 6) // 1000 USDC + + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(mockBalance) + + const balance = await activities.checkUSDCBalance() + + expect(balance).toBe(mockBalance) + expect(mockedBaseMainnetClient.readContract).toHaveBeenCalledWith({ + address: usdcAddress[8453], + abi: expect.any(Array), + functionName: 'balanceOf', + args: [expect.any(String)], + }) + }) + }) + + describe('checkUsdcBalanceOf', () => { + it('should return USDC balance for a specific address', async () => { + const testAddress = '0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd' as Address + const mockBalance = parseUnits('50', 6) // 50 USDC + + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(mockBalance) + + const balance = await activities.checkUsdcBalanceOf(testAddress) + + expect(balance).toBe(mockBalance) + expect(mockedBaseMainnetClient.readContract).toHaveBeenCalledWith({ + address: usdcAddress[8453], + abi: expect.any(Array), + functionName: 'balanceOf', + args: [testAddress], + }) + }) + }) + + describe('sendUsdc', () => { + it('should send USDC to an address', async () => { + const toAddress = '0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd' as Address + const amount = parseUnits('80', 6) // 80 USDC + const mockTxHash = '0xusdctxhash' as Hex + + const mockWalletClient = { + writeContract: jest.fn<() => Promise>().mockResolvedValue(mockTxHash), + } + + mockedCreateWalletClient.mockReturnValueOnce(mockWalletClient as never) + mockedBaseMainnetClient.waitForTransactionReceipt.mockResolvedValueOnce({ + blockNumber: 123n, + } as never) + + const txHash = await activities.sendUsdc(toAddress, amount) + + expect(txHash).toBe(mockTxHash) + expect(mockWalletClient.writeContract).toHaveBeenCalledWith({ + address: usdcAddress[8453], + abi: expect.any(Array), + functionName: 'transfer', + args: [toAddress, amount], + }) + }) + }) + + describe('calculateTotalETHNeeded', () => { + it('should calculate total ETH needed', async () => { + const mockConfigs: AccountConfig[] = [ + { + address: '0x1111111111111111111111111111111111111111' as Address, + name: 'Account 1', + type: 'eth_transfer', + minThreshold: parseEther('0.5'), + targetBalance: parseEther('2'), + }, + { + address: '0x592e1224D203Be4214B15e205F6081FbbaCFcD2D' as Address, + name: 'Account 2', + type: 'paymaster_deposit', + minThreshold: parseEther('0.1'), + targetBalance: parseEther('1'), + }, + { + address: '0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd' as Address, + name: 'Preburn USDC', + type: 'usdc_transfer', + minThreshold: parseUnits('20', 6), + targetBalance: parseUnits('100', 6), + }, + ] + + // Mock balances below threshold + mockedBaseMainnetClient.getBalance.mockResolvedValueOnce(parseEther('0.3')) // Account 1 + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(parseEther('0.05')) // Account 2 + + const totalNeeded = await activities.calculateTotalETHNeeded(mockConfigs) + + // Account 1 needs: 2 - 0.3 = 1.7 ETH + // Account 2 needs: 1 - 0.05 = 0.95 ETH + // Total: 2.65 ETH (no buffer) + const expectedTotal = parseEther('2.65') + + expect(totalNeeded).toBe(expectedTotal) + }) + + it('should return 0 if all accounts are above threshold', async () => { + const mockConfigs: AccountConfig[] = [ + { + address: '0x1111111111111111111111111111111111111111' as Address, + name: 'Account 1', + type: 'eth_transfer', + minThreshold: parseEther('0.5'), + targetBalance: parseEther('2'), + }, + ] + + // Mock balance above threshold + mockedBaseMainnetClient.getBalance.mockResolvedValueOnce(parseEther('1')) + + const totalNeeded = await activities.calculateTotalETHNeeded(mockConfigs) + + expect(totalNeeded).toBe(0n) + }) + }) + + describe('calculateTotalUSDCNeeded', () => { + it('should calculate total USDC needed for USDC accounts', async () => { + const mockConfigs: AccountConfig[] = [ + { + address: '0x1111111111111111111111111111111111111111' as Address, + name: 'ETH Account', + type: 'eth_transfer', + minThreshold: parseEther('0.5'), + targetBalance: parseEther('2'), + }, + { + address: '0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd' as Address, + name: 'Preburn', + type: 'usdc_transfer', + minThreshold: parseUnits('20', 6), // 20 USDC + targetBalance: parseUnits('100', 6), // 100 USDC + }, + ] + + // Mock USDC balance below threshold (15 USDC) + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(parseUnits('15', 6)) + + const totalNeeded = await activities.calculateTotalUSDCNeeded(mockConfigs) + + // Preburn needs: 100 - 15 = 85 USDC + const expectedTotal = parseUnits('85', 6) + + expect(totalNeeded).toBe(expectedTotal) + }) + + it('should return 0 if all USDC accounts are above threshold', async () => { + const mockConfigs: AccountConfig[] = [ + { + address: '0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd' as Address, + name: 'Preburn', + type: 'usdc_transfer', + minThreshold: parseUnits('20', 6), + targetBalance: parseUnits('100', 6), + }, + ] + + // Mock balance above threshold (50 USDC) + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(parseUnits('50', 6)) + + const totalNeeded = await activities.calculateTotalUSDCNeeded(mockConfigs) + + expect(totalNeeded).toBe(0n) + }) + + it('should skip non-USDC accounts', async () => { + const mockConfigs: AccountConfig[] = [ + { + address: '0x1111111111111111111111111111111111111111' as Address, + name: 'ETH Account', + type: 'eth_transfer', + minThreshold: parseEther('0.5'), + targetBalance: parseEther('2'), + }, + ] + + const totalNeeded = await activities.calculateTotalUSDCNeeded(mockConfigs) + + expect(totalNeeded).toBe(0n) + expect(mockedBaseMainnetClient.readContract).not.toHaveBeenCalled() + }) + }) + + describe('checkAndTopOffAccount', () => { + it('should not top off if balance is sufficient', async () => { + const mockConfig: AccountConfig = { + address: '0x1111111111111111111111111111111111111111' as Address, + name: 'Test Account', + type: 'eth_transfer', + minThreshold: parseEther('0.5'), + targetBalance: parseEther('2'), + } + + mockedBaseMainnetClient.getBalance.mockResolvedValueOnce(parseEther('1')) + + const result = await activities.checkAndTopOffAccount(mockConfig) + + expect(result.topped).toBe(false) + expect(result.txHash).toBeUndefined() + }) + + it('should top off ETH transfer account', async () => { + const mockConfig: AccountConfig = { + address: '0x1111111111111111111111111111111111111111' as Address, + name: 'Test Account', + type: 'eth_transfer', + minThreshold: parseEther('0.5'), + targetBalance: parseEther('2'), + } + + const mockWalletClient = { + sendTransaction: jest.fn<() => Promise>().mockResolvedValue('0xtxhash' as Hex), + } + + mockedCreateWalletClient.mockReturnValue(mockWalletClient as never) + mockedBaseMainnetClient.getBalance.mockResolvedValueOnce(parseEther('0.3')) + mockedBaseMainnetClient.waitForTransactionReceipt.mockResolvedValueOnce({ + blockNumber: 123n, + } as never) + + const result = await activities.checkAndTopOffAccount(mockConfig) + + expect(result.topped).toBe(true) + expect(result.txHash).toBe('0xtxhash') + expect(mockWalletClient.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: mockConfig.address, + value: parseEther('1.7'), // 2 - 0.3 + }) + ) + }) + + it('should top off USDC transfer account', async () => { + const mockConfig: AccountConfig = { + address: '0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd' as Address, + name: 'Preburn', + type: 'usdc_transfer', + minThreshold: parseUnits('20', 6), // 20 USDC + targetBalance: parseUnits('100', 6), // 100 USDC + } + + const mockWalletClient = { + writeContract: jest.fn<() => Promise>().mockResolvedValue('0xusdctx' as Hex), + } + + mockedCreateWalletClient.mockReturnValue(mockWalletClient as never) + mockedBaseMainnetClient.readContract.mockResolvedValueOnce(parseUnits('15', 6)) // 15 USDC + mockedBaseMainnetClient.waitForTransactionReceipt.mockResolvedValueOnce({ + blockNumber: 123n, + } as never) + + const result = await activities.checkAndTopOffAccount(mockConfig) + + expect(result.topped).toBe(true) + expect(result.txHash).toBe('0xusdctx') + expect(result.currentBalance).toBe('15') + expect(mockWalletClient.writeContract).toHaveBeenCalledWith({ + address: usdcAddress[8453], + abi: expect.any(Array), + functionName: 'transfer', + args: [mockConfig.address, parseUnits('85', 6)], // 100 - 15 + }) + }) + }) +}) diff --git a/packages/workflows/src/topoff-workflow/activities.ts b/packages/workflows/src/topoff-workflow/activities.ts new file mode 100644 index 000000000..fca9c3b1c --- /dev/null +++ b/packages/workflows/src/topoff-workflow/activities.ts @@ -0,0 +1,454 @@ +import { ApplicationFailure } from '@temporalio/common' +import { baseMainnetClient } from '@my/wagmi' +import { + sendVerifyingPaymasterAbi, + sendVerifyingPaymasterAddress, + tokenPaymasterAbi, + tokenPaymasterAddress, + usdcAbi, + usdcAddress, +} from '@my/wagmi/generated' +import debug from 'debug' +import { + createWalletClient, + http, + type Address, + formatEther, + parseEther, + type Hex, + formatUnits, + parseUnits, + erc20Abi, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { baseMainnet } from '@my/wagmi/chains' +import { BUNDLER_ADDRESS, type AccountConfig } from './config' + +const log = debug('workflows:topoff') + +type TopoffActivities = { + checkEthBalance: (address: Address) => Promise + checkPaymasterDeposit: (address: Address) => Promise + sendEth: (to: Address, amount: bigint) => Promise + sendBundlerSelfTransaction: () => Promise + depositToPaymaster: (address: Address, amount: bigint) => Promise + checkAndTopOffAccount: (config: AccountConfig) => Promise<{ + address: Address + name: string + currentBalance: string + topped: boolean + txHash?: Hex + }> + checkFundingWalletEthBalance: () => Promise + checkUSDCBalance: () => Promise + checkUsdcBalanceOf: (address: Address) => Promise + sendUsdc: (to: Address, amount: bigint) => Promise + calculateTotalETHNeeded: (configs: AccountConfig[]) => Promise + calculateTotalUSDCNeeded: (configs: AccountConfig[]) => Promise + logTopOffSummary: ( + results: Array<{ + address: Address + name: string + currentBalance: string + topped: boolean + txHash?: Hex + }> + ) => Promise +} + +export const createTopoffActivities = ( + env: Record +): TopoffActivities => { + /** + * Get the funding wallet from environment variable + */ + function getFundingWallet() { + const privateKey = env.FUNDING_TOPOFF_PRIVATE_KEY ?? process.env.FUNDING_TOPOFF_PRIVATE_KEY + if (!privateKey) { + throw ApplicationFailure.nonRetryable( + 'FUNDING_TOPOFF_PRIVATE_KEY environment variable is required', + 'ConfigurationError' + ) + } + return privateKeyToAccount(privateKey as Hex) + } + + /** + * Check ETH balance of an address + */ + async function checkEthBalance(address: Address): Promise { + const balance = await baseMainnetClient.getBalance({ address }) + log(`ETH balance for ${address}: ${formatEther(balance)} ETH`) + return balance + } + + /** + * Check paymaster deposit balance + */ + async function checkPaymasterDeposit(address: Address): Promise { + const chainId = baseMainnetClient.chain.id + let balance: bigint + + if (address.toLowerCase() === tokenPaymasterAddress[chainId].toLowerCase()) { + balance = await baseMainnetClient.readContract({ + address: tokenPaymasterAddress[chainId], + abi: tokenPaymasterAbi, + functionName: 'getDeposit', + }) + } else if (address.toLowerCase() === sendVerifyingPaymasterAddress[chainId].toLowerCase()) { + balance = await baseMainnetClient.readContract({ + address: sendVerifyingPaymasterAddress[chainId], + abi: sendVerifyingPaymasterAbi, + functionName: 'getDeposit', + }) + } else { + throw ApplicationFailure.nonRetryable( + `Unknown paymaster address: ${address}`, + 'ConfigurationError' + ) + } + + log(`Paymaster deposit for ${address}: ${formatEther(balance)} ETH`) + return balance + } + + /** + * Send ETH to an address + */ + async function sendEth(to: Address, amount: bigint): Promise { + const account = getFundingWallet() + + const walletClient = createWalletClient({ + account, + chain: baseMainnet, + transport: http(baseMainnet.rpcUrls.default.http[0]), + }) + + log(`Sending ${formatEther(amount)} ETH to ${to}`) + + const hash = await walletClient.sendTransaction({ + to, + value: amount, + }) + + log(`Transaction sent: ${hash}`) + + // Wait for transaction confirmation + const receipt = await baseMainnetClient.waitForTransactionReceipt({ hash }) + log(`Transaction confirmed in block ${receipt.blockNumber}`) + + return hash + } + + /** + * Send 0 ETH transaction to bundler (for bundler restart) + */ + async function sendBundlerSelfTransaction(): Promise { + // Send 0 ETH from funding wallet to bundler to trigger restart + const account = getFundingWallet() + + const walletClient = createWalletClient({ + account, + chain: baseMainnet, + transport: http(baseMainnet.rpcUrls.default.http[0]), + }) + + log(`Sending 0 ETH to bundler (${BUNDLER_ADDRESS}) for restart`) + + const hash = await walletClient.sendTransaction({ + to: BUNDLER_ADDRESS, + value: 0n, + }) + + log(`Bundler restart transaction sent: ${hash}`) + + const receipt = await baseMainnetClient.waitForTransactionReceipt({ hash }) + log(`Bundler restart transaction confirmed in block ${receipt.blockNumber}`) + + return hash + } + + /** + * Deposit ETH to a paymaster contract + */ + async function depositToPaymaster(address: Address, amount: bigint): Promise { + const account = getFundingWallet() + const chainId = baseMainnetClient.chain.id + + const walletClient = createWalletClient({ + account, + chain: baseMainnet, + transport: http(baseMainnet.rpcUrls.default.http[0]), + }) + + log(`Depositing ${formatEther(amount)} ETH to paymaster ${address}`) + + let hash: Hex + + if (address.toLowerCase() === tokenPaymasterAddress[chainId].toLowerCase()) { + hash = await walletClient.writeContract({ + address: tokenPaymasterAddress[chainId], + abi: tokenPaymasterAbi, + functionName: 'deposit', + value: amount, + }) + } else if (address.toLowerCase() === sendVerifyingPaymasterAddress[chainId].toLowerCase()) { + hash = await walletClient.writeContract({ + address: sendVerifyingPaymasterAddress[chainId], + abi: sendVerifyingPaymasterAbi, + functionName: 'deposit', + value: amount, + }) + } else { + throw ApplicationFailure.nonRetryable( + `Unknown paymaster address: ${address}`, + 'ConfigurationError' + ) + } + + log(`Paymaster deposit transaction sent: ${hash}`) + + const receipt = await baseMainnetClient.waitForTransactionReceipt({ hash }) + log(`Paymaster deposit confirmed in block ${receipt.blockNumber}`) + + return hash + } + + /** + * Check and top off a single account + */ + async function checkAndTopOffAccount(config: AccountConfig): Promise<{ + address: Address + name: string + currentBalance: string + topped: boolean + txHash?: Hex + }> { + log(`Checking account: ${config.name} (${config.address})`) + + let currentBalance: bigint + let balanceFormatted: string + + // Check balance based on account type + if (config.type === 'paymaster_deposit') { + currentBalance = await checkPaymasterDeposit(config.address) + balanceFormatted = formatEther(currentBalance) + } else if (config.type === 'usdc_transfer') { + currentBalance = await checkUsdcBalanceOf(config.address) + balanceFormatted = formatUnits(currentBalance, 6) + } else { + currentBalance = await checkEthBalance(config.address) + balanceFormatted = formatEther(currentBalance) + } + + const result = { + address: config.address, + name: config.name, + currentBalance: balanceFormatted, + topped: false, + txHash: undefined as Hex | undefined, + } + + // Check if balance is below threshold + if (currentBalance >= config.minThreshold) { + const unit = config.type === 'usdc_transfer' ? 'USDC' : 'ETH' + log(`✓ ${config.name} balance is sufficient: ${balanceFormatted} ${unit}`) + return result + } + + // Calculate amount to top off + const topOffAmount = config.targetBalance - currentBalance + const unit = config.type === 'usdc_transfer' ? 'USDC' : 'ETH' + + const currentFormatted = + config.type === 'usdc_transfer' ? formatUnits(currentBalance, 6) : formatEther(currentBalance) + const targetFormatted = + config.type === 'usdc_transfer' + ? formatUnits(config.targetBalance, 6) + : formatEther(config.targetBalance) + const amountFormatted = + config.type === 'usdc_transfer' ? formatUnits(topOffAmount, 6) : formatEther(topOffAmount) + + log( + `🔄 ${config.name} needs top-off: current=${currentFormatted} ${unit}, target=${targetFormatted} ${unit}, amount=${amountFormatted} ${unit}` + ) + + // Perform top-off based on account type + let txHash: Hex + + switch (config.type) { + case 'eth_transfer': + txHash = await sendEth(config.address, topOffAmount) + + // If this is the bundler, also send a self-transaction to trigger restart + if (config.address.toLowerCase() === BUNDLER_ADDRESS.toLowerCase()) { + log('Sending bundler self-transaction to trigger restart') + await sendBundlerSelfTransaction() + } + break + + case 'paymaster_deposit': + txHash = await depositToPaymaster(config.address, topOffAmount) + break + + case 'usdc_transfer': + txHash = await sendUsdc(config.address, topOffAmount) + break + + default: + throw ApplicationFailure.nonRetryable( + `Unknown account type: ${config.type}`, + 'ConfigurationError' + ) + } + + result.topped = true + result.txHash = txHash + + log(`✓ ${config.name} topped off successfully: ${txHash}`) + + return result + } + + async function checkFundingWalletEthBalance(): Promise { + const account = getFundingWallet() + const balance = await baseMainnetClient.getBalance({ address: account.address }) + log(`ETH balance for funding wallet: ${formatEther(balance)} ETH`) + return balance + } + + async function checkUSDCBalance(): Promise { + const account = getFundingWallet() + const chainId = baseMainnetClient.chain.id + const balance = await baseMainnetClient.readContract({ + address: usdcAddress[chainId], + abi: erc20Abi, + functionName: 'balanceOf', + args: [account.address], + }) + log(`USDC balance for funding wallet: ${formatUnits(balance, 6)} USDC`) + return balance + } + + async function checkUsdcBalanceOf(address: Address): Promise { + const chainId = baseMainnetClient.chain.id + const balance = await baseMainnetClient.readContract({ + address: usdcAddress[chainId], + abi: erc20Abi, + functionName: 'balanceOf', + args: [address], + }) + log(`USDC balance for ${address}: ${formatUnits(balance, 6)} USDC`) + return balance + } + + async function sendUsdc(to: Address, amount: bigint): Promise { + const account = getFundingWallet() + const chainId = baseMainnetClient.chain.id + + const walletClient = createWalletClient({ + account, + chain: baseMainnet, + transport: http(baseMainnet.rpcUrls.default.http[0]), + }) + + log(`Sending ${formatUnits(amount, 6)} USDC to ${to}`) + + const hash = await walletClient.writeContract({ + address: usdcAddress[chainId], + abi: erc20Abi, + functionName: 'transfer', + args: [to, amount], + }) + + log(`USDC transfer transaction sent: ${hash}`) + + const receipt = await baseMainnetClient.waitForTransactionReceipt({ hash }) + log(`USDC transfer confirmed in block ${receipt.blockNumber}`) + + return hash + } + + async function calculateTotalETHNeeded(configs: AccountConfig[]): Promise { + let totalNeeded = 0n + + for (const config of configs) { + if (config.type === 'usdc_transfer') { + continue + } + + let currentBalance: bigint + if (config.type === 'paymaster_deposit') { + currentBalance = await checkPaymasterDeposit(config.address) + } else { + currentBalance = await checkEthBalance(config.address) + } + + if (currentBalance < config.minThreshold) { + const needed = config.targetBalance - currentBalance + totalNeeded += needed + } + } + + log(`Total ETH needed: ${formatEther(totalNeeded)} ETH`) + + return totalNeeded + } + + async function calculateTotalUSDCNeeded(configs: AccountConfig[]): Promise { + let totalNeeded = 0n + + for (const config of configs) { + if (config.type !== 'usdc_transfer') { + continue + } + + const currentBalance = await checkUsdcBalanceOf(config.address) + + if (currentBalance < config.minThreshold) { + const needed = config.targetBalance - currentBalance + totalNeeded += needed + } + } + + log(`Total USDC needed: ${formatUnits(totalNeeded, 6)} USDC`) + + return totalNeeded + } + + async function logTopOffSummary( + results: Array<{ + address: Address + name: string + currentBalance: string + topped: boolean + txHash?: Hex + }> + ): Promise { + log('=== Top-Off Summary ===') + for (const result of results) { + if (result.topped) { + log(`✓ ${result.name}: Topped off (${result.txHash})`) + } else { + log(`- ${result.name}: No action needed (${result.currentBalance} ETH)`) + } + } + log('======================') + } + + return { + checkEthBalance, + checkPaymasterDeposit, + sendEth, + sendBundlerSelfTransaction, + depositToPaymaster, + checkAndTopOffAccount, + checkFundingWalletEthBalance, + checkUSDCBalance, + checkUsdcBalanceOf, + sendUsdc, + calculateTotalETHNeeded, + calculateTotalUSDCNeeded, + logTopOffSummary, + } +} diff --git a/packages/workflows/src/topoff-workflow/config.ts b/packages/workflows/src/topoff-workflow/config.ts new file mode 100644 index 000000000..55eca4a2e --- /dev/null +++ b/packages/workflows/src/topoff-workflow/config.ts @@ -0,0 +1,63 @@ +import { parseEther, parseUnits, type Address } from 'viem' + +export interface AccountConfig { + address: Address + name: string + type: 'eth_transfer' | 'paymaster_deposit' | 'usdc_transfer' + minThreshold: bigint + targetBalance: bigint +} + +/** + * Bundler address for self-transaction (restart) + */ +export const BUNDLER_ADDRESS: Address = '0x9d1478044F781Ca722ff257e70D05e4Ad673f443' + +/** + * Configuration for all accounts that need automated top-offs + * Runs every 15 minutes to check and top off balances + */ +export const TOPOFF_ACCOUNTS: AccountConfig[] = [ + { + address: BUNDLER_ADDRESS, + name: 'Bundler', + type: 'eth_transfer', // Bundler is just an EOA so that's why its an eth_transfer + minThreshold: parseEther('0.10'), + targetBalance: parseEther('0.25'), + }, + { + address: '0x592e1224D203Be4214B15e205F6081FbbaCFcD2D', + name: 'Transaction Paymaster', + type: 'paymaster_deposit', + minThreshold: parseEther('0.10'), + targetBalance: parseEther('0.25'), + }, + { + address: '0x8A77aE0c07047c5b307B2319A8F4Bd9d3604DdD8', + name: 'Sponsored Paymaster', + type: 'paymaster_deposit', + minThreshold: parseEther('0.10'), + targetBalance: parseEther('0.25'), + }, + { + address: '0xC4b42349E919e6c66B57d4832B20029b3D0f79Bd', + name: 'Preburn', + type: 'usdc_transfer', + minThreshold: parseUnits('20', 6), // 20 USDC + targetBalance: parseUnits('100', 6), // 100 USDC + }, +] +/** + * Schedule interval (cron expression for every 15 minutes) + */ +export const TOPOFF_SCHEDULE_INTERVAL = '*/15 * * * *' + +/** + * Workflow task queue + */ +export const TOPOFF_TASK_QUEUE = 'topoff-workflow' + +/** + * USDC token address on Base + */ +export const USDC_ADDRESS: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' diff --git a/packages/workflows/src/topoff-workflow/start-schedule.ts b/packages/workflows/src/topoff-workflow/start-schedule.ts new file mode 100644 index 000000000..b89331029 --- /dev/null +++ b/packages/workflows/src/topoff-workflow/start-schedule.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Script to create or update the Temporal schedule for account top-offs + * Run this once to set up the recurring schedule, or run it again to update the schedule + * + * Usage: + * tsx packages/workflows/src/topoff-workflow/start-schedule.ts + */ + +import { getTemporalClient } from '@my/temporal/client' +import { TOPOFF_SCHEDULE_INTERVAL } from './config' +import debug from 'debug' + +const log = debug('workflows:topoff:schedule') + +async function main() { + const client = await getTemporalClient() + + const scheduleId = 'account-topoff-schedule' + + try { + // Try to create the schedule + const handle = await client.schedule.create({ + scheduleId, + spec: { + // Run every 15 minutes + cronExpressions: [TOPOFF_SCHEDULE_INTERVAL], + }, + action: { + type: 'startWorkflow', + workflowType: 'topOffAccounts', + taskQueue: 'monorepo@latest', // Use the same task queue as other workflows + args: [], + }, + policies: { + // Only allow one workflow execution at a time + overlap: 'SKIP', + // Keep schedule running even if workflow fails + catchupWindow: '1 minute', + }, + }) + + log(`✓ Schedule created: ${scheduleId}`) + console.log(`Schedule created successfully: ${scheduleId}`) + console.log('Next 5 runs:') + + const description = await handle.describe() + const nextRuns = description.info.nextActionTimes?.slice(0, 5) || [] + for (const run of nextRuns) { + console.log(` - ${run.toISOString()}`) + } + } catch (error: unknown) { + if (error instanceof Error && error.message?.includes('already exists')) { + log(`Schedule ${scheduleId} already exists, updating...`) + console.log( + `Schedule ${scheduleId} already exists. You can delete it first if you want to recreate it.` + ) + console.log(`To delete: temporal schedule delete --schedule-id ${scheduleId}`) + } else { + console.error('Error creating schedule:', error) + throw error + } + } + + await client.connection.close() +} + +main().catch((error) => { + console.error('Failed to create schedule:', error) + process.exit(1) +}) diff --git a/packages/workflows/src/topoff-workflow/workflow.test.ts b/packages/workflows/src/topoff-workflow/workflow.test.ts new file mode 100644 index 000000000..598b8444c --- /dev/null +++ b/packages/workflows/src/topoff-workflow/workflow.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { parseEther, parseUnits, type Address, type Hex } from 'viem' +import type { AccountConfig } from './config' + +// Mock the Temporal workflow module +const mockCalculateTotalETHNeeded = jest.fn<() => Promise>() +const mockCalculateTotalUSDCNeeded = jest.fn<() => Promise>() +const mockCheckFundingWalletEthBalance = jest.fn<() => Promise>() +const mockCheckUSDCBalance = jest.fn<() => Promise>() +const mockCheckAndTopOffAccount = + jest.fn< + () => Promise<{ + address: Address + name: string + currentBalance: string + topped: boolean + txHash?: Hex + }> + >() +const mockLogTopOffSummary = jest.fn<() => Promise>() +const mockWorkflowLog = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +} + +jest.mock('@temporalio/workflow', () => ({ + proxyActivities: jest.fn(() => ({ + calculateTotalETHNeeded: mockCalculateTotalETHNeeded, + calculateTotalUSDCNeeded: mockCalculateTotalUSDCNeeded, + checkFundingWalletEthBalance: mockCheckFundingWalletEthBalance, + checkUSDCBalance: mockCheckUSDCBalance, + checkAndTopOffAccount: mockCheckAndTopOffAccount, + logTopOffSummary: mockLogTopOffSummary, + })), + log: mockWorkflowLog, +})) + +// Mock config +jest.mock('./config', () => ({ + TOPOFF_ACCOUNTS: [ + { + address: '0x1111111111111111111111111111111111111111', + name: 'Account 1', + type: 'eth_transfer', + minThreshold: parseEther('0.5'), + targetBalance: parseEther('2'), + }, + { + address: '0x2222222222222222222222222222222222222222', + name: 'Account 2', + type: 'paymaster_deposit', + minThreshold: parseEther('0.1'), + targetBalance: parseEther('1'), + }, + ] as AccountConfig[], +})) + +// Import after mocks +import { topOffAccounts } from './workflow' +import { TOPOFF_ACCOUNTS } from './config' + +describe('Top-Off Workflow', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should exit early if no ETH or USDC is needed', async () => { + mockCalculateTotalETHNeeded.mockResolvedValueOnce(0n) + mockCalculateTotalUSDCNeeded.mockResolvedValueOnce(0n) + + await topOffAccounts() + + expect(mockCalculateTotalETHNeeded).toHaveBeenCalledWith(TOPOFF_ACCOUNTS) + expect(mockCalculateTotalUSDCNeeded).toHaveBeenCalledWith(TOPOFF_ACCOUNTS) + expect(mockCheckFundingWalletEthBalance).not.toHaveBeenCalled() + expect(mockCheckUSDCBalance).not.toHaveBeenCalled() + expect(mockCheckAndTopOffAccount).not.toHaveBeenCalled() + expect(mockWorkflowLog.info).toHaveBeenCalledWith( + 'No top-offs needed, all accounts have sufficient balance' + ) + }) + + it('should check funding wallet ETH balance and warn if insufficient', async () => { + const totalETHNeeded = parseEther('2') + const totalUSDCNeeded = parseUnits('0', 6) + const ethBalance = parseEther('1') // Only have 1 ETH, need 2 + + mockCalculateTotalETHNeeded.mockResolvedValueOnce(totalETHNeeded) + mockCalculateTotalUSDCNeeded.mockResolvedValueOnce(totalUSDCNeeded) + mockCheckFundingWalletEthBalance.mockResolvedValueOnce(ethBalance) + mockCheckAndTopOffAccount.mockResolvedValue({ + address: '0x1111111111111111111111111111111111111111', + name: 'Account 1', + currentBalance: '0.5', + topped: false, + }) + + await topOffAccounts() + + expect(mockCalculateTotalETHNeeded).toHaveBeenCalledWith(TOPOFF_ACCOUNTS) + expect(mockCalculateTotalUSDCNeeded).toHaveBeenCalledWith(TOPOFF_ACCOUNTS) + expect(mockCheckFundingWalletEthBalance).toHaveBeenCalled() + expect(mockWorkflowLog.warn).toHaveBeenCalledWith( + expect.stringContaining('Insufficient ETH in funding wallet') + ) + expect(mockCheckAndTopOffAccount).toHaveBeenCalledTimes(TOPOFF_ACCOUNTS.length) + }) + + it('should check funding wallet USDC balance and warn if insufficient', async () => { + const totalETHNeeded = parseEther('0') + const totalUSDCNeeded = parseUnits('100', 6) + const usdcBalance = parseUnits('50', 6) // Only have 50 USDC, need 100 + + mockCalculateTotalETHNeeded.mockResolvedValueOnce(totalETHNeeded) + mockCalculateTotalUSDCNeeded.mockResolvedValueOnce(totalUSDCNeeded) + mockCheckUSDCBalance.mockResolvedValueOnce(usdcBalance) + mockCheckAndTopOffAccount.mockResolvedValue({ + address: '0x1111111111111111111111111111111111111111', + name: 'Account 1', + currentBalance: '0.5', + topped: false, + }) + + await topOffAccounts() + + expect(mockCalculateTotalETHNeeded).toHaveBeenCalledWith(TOPOFF_ACCOUNTS) + expect(mockCalculateTotalUSDCNeeded).toHaveBeenCalledWith(TOPOFF_ACCOUNTS) + expect(mockCheckUSDCBalance).toHaveBeenCalled() + expect(mockWorkflowLog.warn).toHaveBeenCalledWith( + expect.stringContaining('Insufficient USDC in funding wallet') + ) + expect(mockCheckAndTopOffAccount).toHaveBeenCalledTimes(TOPOFF_ACCOUNTS.length) + }) + + it('should perform all top-offs sequentially', async () => { + const totalETHNeeded = parseEther('0') // No ETH needed + const totalUSDCNeeded = parseUnits('100', 6) // But USDC needed + const usdcBalance = parseUnits('1000', 6) // Sufficient USDC + + mockCalculateTotalETHNeeded.mockResolvedValueOnce(totalETHNeeded) + mockCalculateTotalUSDCNeeded.mockResolvedValueOnce(totalUSDCNeeded) + mockCheckUSDCBalance.mockResolvedValueOnce(usdcBalance) + + const mockResult = { + address: + TOPOFF_ACCOUNTS[0]?.address ?? ('0x0000000000000000000000000000000000000000' as Address), + name: TOPOFF_ACCOUNTS[0]?.name ?? 'Default', + currentBalance: '1', + topped: false, + } + + mockCheckAndTopOffAccount.mockResolvedValue(mockResult) + + await topOffAccounts() + + // Verify all accounts were processed + expect(mockCheckAndTopOffAccount).toHaveBeenCalledTimes(TOPOFF_ACCOUNTS.length) + + // Verify each account config was passed + for (const account of TOPOFF_ACCOUNTS) { + expect(mockCheckAndTopOffAccount).toHaveBeenCalledWith(account) + } + + expect(mockLogTopOffSummary).toHaveBeenCalled() + }) + + it('should log comprehensive workflow progress', async () => { + const totalETHNeeded = parseEther('0.5') + const totalUSDCNeeded = parseUnits('20', 6) + const ethBalance = parseEther('5') + const usdcBalance = parseUnits('1000', 6) + + mockCalculateTotalETHNeeded.mockResolvedValueOnce(totalETHNeeded) + mockCalculateTotalUSDCNeeded.mockResolvedValueOnce(totalUSDCNeeded) + mockCheckFundingWalletEthBalance.mockResolvedValueOnce(ethBalance) + mockCheckUSDCBalance.mockResolvedValueOnce(usdcBalance) + mockCheckAndTopOffAccount.mockResolvedValue({ + address: '0x1111111111111111111111111111111111111111', + name: 'Account 1', + currentBalance: '1', + topped: true, + txHash: '0xtopoff', + }) + + await topOffAccounts() + + expect(mockWorkflowLog.info).toHaveBeenCalledWith('Starting top-off workflow') + expect(mockWorkflowLog.info).toHaveBeenCalledWith(expect.stringContaining('Total ETH needed')) + expect(mockWorkflowLog.info).toHaveBeenCalledWith(expect.stringContaining('Total USDC needed')) + expect(mockWorkflowLog.info).toHaveBeenCalledWith('Starting account top-offs') + expect(mockWorkflowLog.info).toHaveBeenCalledWith('Top-off workflow completed') + }) +}) diff --git a/packages/workflows/src/topoff-workflow/workflow.ts b/packages/workflows/src/topoff-workflow/workflow.ts new file mode 100644 index 000000000..7d0185ba1 --- /dev/null +++ b/packages/workflows/src/topoff-workflow/workflow.ts @@ -0,0 +1,79 @@ +import { proxyActivities, log as workflowLog } from '@temporalio/workflow' +import type * as activities from './activities' +import { TOPOFF_ACCOUNTS } from './config' + +const { + checkAndTopOffAccount, + logTopOffSummary, + calculateTotalETHNeeded, + calculateTotalUSDCNeeded, + checkFundingWalletEthBalance, + checkUSDCBalance, +} = proxyActivities({ + startToCloseTimeout: '5 minutes', + // No retry policy - activities throw non-retryable errors + // This is a cron job running every 15 minutes, so we fail fast + // and let the next scheduled run handle any issues +}) + +/** + * Top-off workflow that checks and tops off all configured accounts + * Runs on a schedule (every 15 minutes by default) + * + * Process: + * 1. Calculate total ETH and USDC needed for all top-offs + * 2. Check funding wallet has sufficient ETH and USDC + * 3. If sufficient funds, perform all top-offs sequentially + * 4. Log summary + */ +export async function topOffAccounts(): Promise { + workflowLog.info('Starting top-off workflow') + + // Calculate how much ETH and USDC we need for all top-offs + const totalETHNeeded = await calculateTotalETHNeeded(TOPOFF_ACCOUNTS) + const totalUSDCNeeded = await calculateTotalUSDCNeeded(TOPOFF_ACCOUNTS) + + workflowLog.info(`Total ETH needed: ${totalETHNeeded.toString()} wei`) + workflowLog.info(`Total USDC needed: ${totalUSDCNeeded.toString()} (6 decimals)`) + + if (totalETHNeeded === 0n && totalUSDCNeeded === 0n) { + workflowLog.info('No top-offs needed, all accounts have sufficient balance') + return + } + + // Check if funding wallet has sufficient balances + if (totalETHNeeded > 0n) { + const ethBalance = await checkFundingWalletEthBalance() + workflowLog.info(`Funding wallet ETH balance: ${ethBalance.toString()} wei`) + + if (ethBalance < totalETHNeeded) { + workflowLog.warn( + `Insufficient ETH in funding wallet: need ${totalETHNeeded.toString()} wei, have ${ethBalance.toString()} wei. Skipping ETH top-offs.` + ) + } + } + + if (totalUSDCNeeded > 0n) { + const usdcBalance = await checkUSDCBalance() + workflowLog.info(`Funding wallet USDC balance: ${usdcBalance.toString()} (6 decimals)`) + + if (usdcBalance < totalUSDCNeeded) { + workflowLog.warn( + `Insufficient USDC in funding wallet: need ${totalUSDCNeeded.toString()}, have ${usdcBalance.toString()}. Skipping USDC top-offs.` + ) + } + } + + // Check and top off all accounts sequentially for better visibility + workflowLog.info('Starting account top-offs') + const results: Awaited>[] = [] + for (const account of TOPOFF_ACCOUNTS) { + const result = await checkAndTopOffAccount(account) + results.push(result) + } + + // Log summary + await logTopOffSummary(results) + + workflowLog.info('Top-off workflow completed') +} diff --git a/packages/workflows/src/utils/bootstrap.ts b/packages/workflows/src/utils/bootstrap.ts index e471e61cc..a47ec30e1 100644 --- a/packages/workflows/src/utils/bootstrap.ts +++ b/packages/workflows/src/utils/bootstrap.ts @@ -7,6 +7,7 @@ const requiredEnvVars = [ 'SUPABASE_DB_URL', 'SUPABASE_JWT_SECRET', 'SUPABASE_SERVICE_ROLE', + 'FUNDING_TOPOFF_PRIVATE_KEY', ] as const const optionalEnvVars = ['DEBUG']