From 95b7a904642da0d237085b46c6ec72374450a439 Mon Sep 17 00:00:00 2001 From: lgalende Date: Tue, 25 Nov 2025 16:36:16 -0300 Subject: [PATCH 1/2] examples: add fee collection example --- examples/14-fee-collection/abis/ERC20.json | 222 ++++++++++++++++++ examples/14-fee-collection/eslint.config.mjs | 89 +++++++ examples/14-fee-collection/manifest.yaml | 8 + examples/14-fee-collection/package.json | 31 +++ examples/14-fee-collection/src/task.ts | 71 ++++++ examples/14-fee-collection/tests/task.spec.ts | 165 +++++++++++++ .../14-fee-collection/tests/tsconfig.json | 21 ++ examples/14-fee-collection/tsconfig.json | 6 + 8 files changed, 613 insertions(+) create mode 100644 examples/14-fee-collection/abis/ERC20.json create mode 100644 examples/14-fee-collection/eslint.config.mjs create mode 100644 examples/14-fee-collection/manifest.yaml create mode 100644 examples/14-fee-collection/package.json create mode 100644 examples/14-fee-collection/src/task.ts create mode 100644 examples/14-fee-collection/tests/task.spec.ts create mode 100644 examples/14-fee-collection/tests/tsconfig.json create mode 100644 examples/14-fee-collection/tsconfig.json diff --git a/examples/14-fee-collection/abis/ERC20.json b/examples/14-fee-collection/abis/ERC20.json new file mode 100644 index 0000000..405d6b3 --- /dev/null +++ b/examples/14-fee-collection/abis/ERC20.json @@ -0,0 +1,222 @@ +[ + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_spender", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [ + { + "name": "", + "type": "uint8" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "name": "balance", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + }, + { + "name": "_spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "payable": true, + "stateMutability": "payable", + "type": "fallback" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "from", + "type": "address" + }, + { + "indexed": true, + "name": "to", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + } +] diff --git a/examples/14-fee-collection/eslint.config.mjs b/examples/14-fee-collection/eslint.config.mjs new file mode 100644 index 0000000..90af2c8 --- /dev/null +++ b/examples/14-fee-collection/eslint.config.mjs @@ -0,0 +1,89 @@ +import eslintPluginTypeScript from "@typescript-eslint/eslint-plugin" +import eslintParserTypeScript from "@typescript-eslint/parser" +import eslintPluginImport from "eslint-plugin-import" +import eslintPluginSimpleImportSort from "eslint-plugin-simple-import-sort" +import eslintConfigPrettier from "eslint-config-prettier" +import eslintPluginPrettier from "eslint-plugin-prettier" + +export default [ + { + ignores: ["node_modules/**", "**/dist/**", "**/build/**", "**/.prettierrc.*", "./src/types/**"] + }, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + parser: eslintParserTypeScript, + parserOptions: { + project: "./tsconfig.json" + } + }, + plugins: { + "@typescript-eslint": eslintPluginTypeScript, + prettier: eslintPluginPrettier, + import: eslintPluginImport, + "simple-import-sort": eslintPluginSimpleImportSort + }, + rules: { + ...eslintPluginTypeScript.configs.recommended.rules, + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-unused-vars": ["error"], + "@typescript-eslint/explicit-function-return-type": "error", + "@typescript-eslint/no-explicit-any": "error", + + "prettier/prettier": [ + "error", + { + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "always", + "bracketSpacing": true, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false + } + ], + + "simple-import-sort/imports": [ + "error", + { + groups: [ + ["^@?\\w"], + ["^\\.\\.(?!/?$)", "^\\.\\./?$"], + ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"] + ] + } + ], + "simple-import-sort/exports": "error", + + "comma-spacing": ["error", { before: false, after: true }], + "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }] + }, + settings: { + "import/resolver": { + typescript: { + alwaysTryTypes: true, + project: "./tsconfig.json" + } + } + } + }, + // configuration for test files + { + files: ["tests/**/*.{ts,tsx}", "**/*.spec.{ts,tsx}", "**/*.test.{ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + parser: eslintParserTypeScript, + parserOptions: { + project: "./tests/tsconfig.json" + } + }, + rules: { + "@typescript-eslint/no-unused-expressions": "off" + } + }, + eslintConfigPrettier +] \ No newline at end of file diff --git a/examples/14-fee-collection/manifest.yaml b/examples/14-fee-collection/manifest.yaml new file mode 100644 index 0000000..de1b926 --- /dev/null +++ b/examples/14-fee-collection/manifest.yaml @@ -0,0 +1,8 @@ +version: 1.0.0 +name: Collect Task +description: Swaps all user tokens for USDC and sends the USDC to a recipient +inputs: + - chainId: int32 + - tokensCsv: string + - slippageBps: uint16 # e.g., 50 = 0.50% + - recipient: address diff --git a/examples/14-fee-collection/package.json b/examples/14-fee-collection/package.json new file mode 100644 index 0000000..56c081c --- /dev/null +++ b/examples/14-fee-collection/package.json @@ -0,0 +1,31 @@ +{ + "name": "@mimicprotocol/14-fee-collection", + "version": "0.0.1", + "license": "Unlicensed", + "private": true, + "type": "module", + "scripts": { + "build": "yarn codegen && yarn compile", + "codegen": "mimic codegen", + "compile": "mimic compile", + "test": "mimic test", + "lint": "eslint ." + }, + "devDependencies": { + "@mimicprotocol/cli": "latest", + "@mimicprotocol/lib-ts": "latest", + "@mimicprotocol/sdk": "latest", + "@mimicprotocol/test-ts": "latest", + "@types/chai": "^5.2.2", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.5", + "assemblyscript": "0.27.36", + "chai": "^4.3.7", + "eslint": "^9.10.0", + "json-as": "1.1.7", + "mocha": "^10.2.0", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "visitor-as": "0.11.4" + } +} diff --git a/examples/14-fee-collection/src/task.ts b/examples/14-fee-collection/src/task.ts new file mode 100644 index 0000000..338c018 --- /dev/null +++ b/examples/14-fee-collection/src/task.ts @@ -0,0 +1,71 @@ +import { + Arbitrum, + Base, + BigInt, + BlockchainToken, + ChainId, + environment, + ERC20Token, + ListType, + log, + Optimism, + SwapBuilder, + Token, + USD, +} from '@mimicprotocol/lib-ts' + +import { inputs } from './types' + +const BPS_DENOMINATOR = BigInt.fromI32(10_000) + +export default function main(): void { + const chainId = inputs.chainId + const context = environment.getContext() + + // Find tokens with user's balance > 0 + const tokenList = buildTokenList(inputs.tokensCsv, chainId) + const amountsIn = environment.getRelevantTokens(context.user, [chainId], USD.zero(), tokenList, ListType.AllowList) + + if (amountsIn.length == 0) { + log.info(`No tokens found on chain ${chainId}`) + return + } + + const USDC = getUsdc(chainId) + const slippageFactor = BPS_DENOMINATOR.minus(BigInt.fromI32(inputs.slippageBps as i32)) + + for (let i = 0; i < amountsIn.length; i++) { + const amountIn = amountsIn[i] + const amountOut = amountIn.toTokenAmount(USDC) + const minAmountOut = amountOut.times(slippageFactor).div(BPS_DENOMINATOR) + + // Note that the recipient will receive the USDC + SwapBuilder.forChain(chainId) + .addTokenInFromTokenAmount(amountIn) + .addTokenOutFromTokenAmount(minAmountOut, inputs.recipient) + .build() + .send() + + log.info(`Adding swap of ${amountIn} to ${minAmountOut} on chain ${chainId}`) + } +} + +function buildTokenList(tokensCsv: string, chainId: u32): BlockchainToken[] { + const list = new Array() + + const tokenAddresses = tokensCsv.split(',') + for (let i = 0; i < tokenAddresses.length; i++) { + const tokenAddress = tokenAddresses[i] + const erc20 = ERC20Token.fromString(tokenAddress, chainId) + list.push(changetype(erc20)) + } + + return list +} + +function getUsdc(chainId: i32): Token { + if (chainId == ChainId.ARBITRUM) return Arbitrum.USDC + if (chainId == ChainId.BASE) return Base.USDC + if (chainId == ChainId.OPTIMISM) return Optimism.USDC + throw new Error('Invalid chain') +} diff --git a/examples/14-fee-collection/tests/task.spec.ts b/examples/14-fee-collection/tests/task.spec.ts new file mode 100644 index 0000000..da56a0d --- /dev/null +++ b/examples/14-fee-collection/tests/task.spec.ts @@ -0,0 +1,165 @@ +import { Chains, fp, OpType, randomEvmAddress } from '@mimicprotocol/sdk' +import { Context, ContractCallMock, GetPriceMock, GetRelevantTokensMock, runTask, Swap } from '@mimicprotocol/test-ts' +import { expect } from 'chai' +import { Interface } from 'ethers' + +import ERC20Abi from '../abis/ERC20.json' + +const ERC20Interface = new Interface(ERC20Abi) + +describe('Task', () => { + const taskDir = './build' + + const chainId = Chains.Base + const USDC = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + const WETH = randomEvmAddress() + const WBTC = randomEvmAddress() + + const context: Context = { + user: randomEvmAddress(), + settlers: [{ address: randomEvmAddress(), chainId }], + timestamp: Date.now(), + } + + const inputs = { + chainId, + tokensCsv: `${WETH},${WBTC}`, + slippageBps: 50, // 0.5% + recipient: randomEvmAddress(), + } + + const prices: GetPriceMock[] = [ + { request: { token: USDC, chainId }, response: [fp(1).toString()] }, // 1 USDC = 1 USD + { request: { token: WETH, chainId }, response: [fp(50).toString()] }, // 1 WETH = 50 USD + { request: { token: WBTC, chainId }, response: [fp(100).toString()] }, // 1 WBTC = 100 USD + ] + + const calls: ContractCallMock[] = [ + // USDC + { + request: { chainId, to: USDC, fnSelector: ERC20Interface.getFunction('decimals')!.selector }, + response: { value: '6', abiType: 'uint8' }, + }, + { + request: { chainId, to: USDC, fnSelector: ERC20Interface.getFunction('symbol')!.selector }, + response: { value: 'USDC', abiType: 'string' }, + }, + // WETH + { + request: { chainId, to: WETH, fnSelector: ERC20Interface.getFunction('decimals')!.selector }, + response: { value: '18', abiType: 'uint8' }, + }, + { + request: { chainId, to: WETH, fnSelector: ERC20Interface.getFunction('symbol')!.selector }, + response: { value: 'WETH', abiType: 'string' }, + }, + // WBTC + { + request: { chainId, to: WBTC, fnSelector: ERC20Interface.getFunction('decimals')!.selector }, + response: { value: '8', abiType: 'uint8' }, + }, + { + request: { chainId, to: WBTC, fnSelector: ERC20Interface.getFunction('symbol')!.selector }, + response: { value: 'WBTC', abiType: 'string' }, + }, + ] + + describe('when the user has some balance for the requested tokens', () => { + const relevantTokens: GetRelevantTokensMock[] = [ + { + request: { + owner: context.user!, + chainIds: [chainId], + usdMinAmount: '0', + tokenFilter: 0, + tokens: [ + { address: WETH, chainId }, + { address: WBTC, chainId }, + ], + }, + response: [ + { + timestamp: Date.now(), + balances: [ + { token: { address: WETH, chainId }, balance: fp(1).toString() }, // 1 WETH + { token: { address: WBTC, chainId }, balance: fp(5, 8).toString() }, // 5 WBTC + ], + }, + ], + }, + ] + + it('produces the expected intents for multiple tokens', async () => { + const result = await runTask(taskDir, context, { inputs, calls, prices, relevantTokens }) + expect(result.success).to.be.true + expect(result.timestamp).to.be.equal(context.timestamp) + + const intents = result.intents as Swap[] + expect(intents).to.have.lengthOf(2) + + const firstSwap = intents[0] + expect(firstSwap.op).to.equal(OpType.Swap) + expect(firstSwap.settler).to.equal(context.settlers![0].address) + expect(firstSwap.user).to.equal(context.user) + expect(firstSwap.sourceChain).to.equal(inputs.chainId) + expect(firstSwap.destinationChain).to.equal(inputs.chainId) + + expect(firstSwap.tokensIn).to.have.lengthOf(1) + expect(firstSwap.tokensIn[0].token).to.equal(WETH) + expect(firstSwap.tokensIn[0].amount).to.equal(fp(1).toString()) + + expect(firstSwap.tokensOut).to.have.lengthOf(1) + expect(firstSwap.tokensOut[0].token).to.equal(USDC) + expect(firstSwap.tokensOut[0].minAmount).to.equal('49750000') // 50 USDC with 0.5% slippage + expect(firstSwap.tokensOut[0].recipient).to.equal(inputs.recipient) + + const secondSwap = intents[1] + expect(secondSwap.op).to.equal(OpType.Swap) + expect(secondSwap.settler).to.equal(context.settlers![0].address) + expect(secondSwap.user).to.equal(context.user) + expect(secondSwap.sourceChain).to.equal(inputs.chainId) + expect(secondSwap.destinationChain).to.equal(inputs.chainId) + + expect(secondSwap.tokensIn).to.have.lengthOf(1) + expect(secondSwap.tokensIn[0].token).to.equal(WBTC) + expect(secondSwap.tokensIn[0].amount).to.equal(fp(5, 8).toString()) + + expect(secondSwap.tokensOut).to.have.lengthOf(1) + expect(secondSwap.tokensOut[0].token).to.equal(USDC) + expect(secondSwap.tokensOut[0].minAmount).to.equal('497500000') // 500 USDC with 0.5% slippage + expect(secondSwap.tokensOut[0].recipient).to.equal(inputs.recipient) + + expect(result.logs).to.have.lengthOf(2) + expect(result.logs[0]).to.be.equal(`[Info] Adding swap of 1 WETH to 49.75 USDC on chain ${chainId}`) + expect(result.logs[1]).to.be.equal(`[Info] Adding swap of 5 WBTC to 497.5 USDC on chain ${chainId}`) + }) + }) + + describe('when the user does not have balance for the requested tokens', () => { + const relevantTokens: GetRelevantTokensMock[] = [ + { + request: { + owner: context.user!, + chainIds: [chainId], + usdMinAmount: '0', + tokenFilter: 0, + tokens: [ + { address: WETH, chainId }, + { address: WBTC, chainId }, + ], + }, + response: [{ timestamp: Date.now(), balances: [] }], + }, + ] + + it('does not produce any intents', async () => { + const result = await runTask(taskDir, context, { inputs, calls, prices, relevantTokens }) + expect(result.success).to.be.true + expect(result.timestamp).to.be.equal(context.timestamp) + expect(result.intents).to.be.empty + + expect(result.logs).to.have.lengthOf(1) + expect(result.logs[0]).to.be.equal(`[Info] No tokens found on chain ${chainId}`) + }) + }) +}) diff --git a/examples/14-fee-collection/tests/tsconfig.json b/examples/14-fee-collection/tests/tsconfig.json new file mode 100644 index 0000000..821e603 --- /dev/null +++ b/examples/14-fee-collection/tests/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "declaration": true, + "composite": true, + "outDir": "./dist", + "rootDir": "./", + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2020", "DOM"], + "types": ["mocha", "chai", "node"] + }, + "include": ["./**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/14-fee-collection/tsconfig.json b/examples/14-fee-collection/tsconfig.json new file mode 100644 index 0000000..dd7ad20 --- /dev/null +++ b/examples/14-fee-collection/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "assemblyscript/std/assembly.json", + "include": ["./src/**/*.ts"], + "exclude": ["tests/**/*"], + "references": [{ "path": "./tests" }] +} From b0d41042b065a71c0c74a10feecebec8ce12534f Mon Sep 17 00:00:00 2001 From: lgalende Date: Wed, 26 Nov 2025 11:13:57 -0300 Subject: [PATCH 2/2] examples: use deny list for fee collection --- examples/14-fee-collection/src/task.ts | 18 +----------------- examples/14-fee-collection/tests/task.spec.ts | 15 ++++----------- 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/examples/14-fee-collection/src/task.ts b/examples/14-fee-collection/src/task.ts index 338c018..27b5119 100644 --- a/examples/14-fee-collection/src/task.ts +++ b/examples/14-fee-collection/src/task.ts @@ -2,10 +2,8 @@ import { Arbitrum, Base, BigInt, - BlockchainToken, ChainId, environment, - ERC20Token, ListType, log, Optimism, @@ -23,8 +21,7 @@ export default function main(): void { const context = environment.getContext() // Find tokens with user's balance > 0 - const tokenList = buildTokenList(inputs.tokensCsv, chainId) - const amountsIn = environment.getRelevantTokens(context.user, [chainId], USD.zero(), tokenList, ListType.AllowList) + const amountsIn = environment.getRelevantTokens(context.user, [chainId], USD.zero(), [], ListType.DenyList) if (amountsIn.length == 0) { log.info(`No tokens found on chain ${chainId}`) @@ -50,19 +47,6 @@ export default function main(): void { } } -function buildTokenList(tokensCsv: string, chainId: u32): BlockchainToken[] { - const list = new Array() - - const tokenAddresses = tokensCsv.split(',') - for (let i = 0; i < tokenAddresses.length; i++) { - const tokenAddress = tokenAddresses[i] - const erc20 = ERC20Token.fromString(tokenAddress, chainId) - list.push(changetype(erc20)) - } - - return list -} - function getUsdc(chainId: i32): Token { if (chainId == ChainId.ARBITRUM) return Arbitrum.USDC if (chainId == ChainId.BASE) return Base.USDC diff --git a/examples/14-fee-collection/tests/task.spec.ts b/examples/14-fee-collection/tests/task.spec.ts index da56a0d..9ef6e3a 100644 --- a/examples/14-fee-collection/tests/task.spec.ts +++ b/examples/14-fee-collection/tests/task.spec.ts @@ -23,7 +23,6 @@ describe('Task', () => { const inputs = { chainId, - tokensCsv: `${WETH},${WBTC}`, slippageBps: 50, // 0.5% recipient: randomEvmAddress(), } @@ -71,11 +70,8 @@ describe('Task', () => { owner: context.user!, chainIds: [chainId], usdMinAmount: '0', - tokenFilter: 0, - tokens: [ - { address: WETH, chainId }, - { address: WBTC, chainId }, - ], + tokenFilter: 1, + tokens: [], }, response: [ { @@ -142,11 +138,8 @@ describe('Task', () => { owner: context.user!, chainIds: [chainId], usdMinAmount: '0', - tokenFilter: 0, - tokens: [ - { address: WETH, chainId }, - { address: WBTC, chainId }, - ], + tokenFilter: 1, + tokens: [], }, response: [{ timestamp: Date.now(), balances: [] }], },