diff --git a/.changeset/fiery-frogs-exist.md b/.changeset/fiery-frogs-exist.md new file mode 100644 index 00000000..e7db2921 --- /dev/null +++ b/.changeset/fiery-frogs-exist.md @@ -0,0 +1,7 @@ +--- +"@mimicprotocol/lib-ts": patch +"@mimicprotocol/cli": patch +"@mimicprotocol/test-ts": patch +--- + +Add swapAndSplit function diff --git a/packages/lib-ts/src/helpers/constants.ts b/packages/lib-ts/src/helpers/constants.ts index e6b8460f..69d3a01d 100644 --- a/packages/lib-ts/src/helpers/constants.ts +++ b/packages/lib-ts/src/helpers/constants.ts @@ -12,4 +12,5 @@ export enum ListType { DenyList = 1, } -export const MIMIC_HELPER_ADDRESS = '0x5cf82cbed1110fc2f75b3413d53abac492931804' +export const MIMIC_HELPER_ADDRESS = '0x3b4f927bb967ab873725b8b765043b6cfacba38a' +export const MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS = '0xf8425597f287347d049c61f07b5e8652ef09212f' diff --git a/packages/lib-ts/src/helpers/index.ts b/packages/lib-ts/src/helpers/index.ts index ef9389bd..5a995687 100644 --- a/packages/lib-ts/src/helpers/index.ts +++ b/packages/lib-ts/src/helpers/index.ts @@ -5,4 +5,5 @@ export * from './BorshDeserializer' export * from './constants' export * from './serialize' export * from './strings' +export * from './swapAndSplit' export { Consensus, Math } diff --git a/packages/lib-ts/src/helpers/swapAndSplit.ts b/packages/lib-ts/src/helpers/swapAndSplit.ts new file mode 100644 index 00000000..baefe54b --- /dev/null +++ b/packages/lib-ts/src/helpers/swapAndSplit.ts @@ -0,0 +1,105 @@ +import { environment } from '../environment' +import { EvmDynamicArg, EvmDynamicCallBuilder, IntentBuilder, SwapBuilder } from '../intents' +import { TokenAmount } from '../tokens' +import { Address, Bytes, ChainId, EvmEncodeParam } from '../types' + +import { MIMIC_HELPER_ADDRESS, MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS } from './constants' + +const MIMIC_HELPER = Address.fromHexString(MIMIC_HELPER_ADDRESS) +const MIMIC_PUBLIC_SMART_ACCOUNT = Address.fromHexString(MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS) + +const PCT_SELECTOR = Bytes.fromHexString('0xe7032021') +const TRANSFER_SELECTOR = Bytes.fromHexString('0xa9059cbb') +const BALANCE_OF_SELECTOR = Bytes.fromHexString('0x70a08231') + +const MAX_PCT_BPS: u16 = 10_000 + +export class Allocation { + constructor( + public recipient: Address, + public pctBps: u16 + ) {} +} + +/** + * @dev Creates an IntentBuilder containing operations to swap tokens and transfer the output to multiple recipients. + * The last recipient percentage is ignored, and will receive the remaining balance after the other allocations. + * @param chainId The chain ID of the swap and the transfers. + * @param amountIn The amount of tokens to swap. If the token is native, the `user` must be a smart account. + * @param minAmountOut The minimum amount of tokens to receive from the swap. ERC20 tokens only. + * @param allocations An array containing a recipient address and a percentage in basis points (e.g., 50 = 0.5%, 10_000 = 100%). + * It represents how the output of the swap will be split among the recipients. The total allocation must add up to 10_000. + * @param user The user address for the swap (optional). If not provided, the context user will be used. + * @returns An IntentBuilder object that can be used to build and send the intent. + */ +export function buildSwapAndSplit( + chainId: ChainId, + amountIn: TokenAmount, + minAmountOut: TokenAmount, + allocations: Allocation[], + user: Address | null = null +): IntentBuilder { + if (allocations.length <= 1) throw new Error('More than 1 allocation is needed') + + let totalPctBps: u32 = 0 + for (let i = 0; i < allocations.length; i++) totalPctBps += allocations[i].pctBps + if (totalPctBps !== MAX_PCT_BPS) throw new Error('Total allocation percentage must be 10_000 bps') + + const tokenOut = minAmountOut.token.address + if (tokenOut.isNative()) throw new Error('Output token cannot be native') + + const builder = new IntentBuilder() + + const swap = SwapBuilder.forChain(chainId) + .addUser(user || environment.getContext().user) + .addTokenInFromTokenAmount(amountIn) + .addTokenOutFromTokenAmount(minAmountOut, MIMIC_PUBLIC_SMART_ACCOUNT) + + builder.addOperationBuilder(swap) + + // Calculate the corresponding amount for each allocation, except the last one + const dynamicCall1 = EvmDynamicCallBuilder.forChain(chainId).addUser(MIMIC_PUBLIC_SMART_ACCOUNT) + + for (let i = 0; i < allocations.length - 1; i++) { + const pctBps = allocations[i].pctBps + dynamicCall1.addCall(MIMIC_HELPER, PCT_SELECTOR, [ + EvmDynamicArg.variable(0, 0, false), // amount (swap output) + EvmDynamicArg.literal([new EvmEncodeParam('uint16', pctBps.toString())], false), // percent bps + ]) + } + + builder.addOperationBuilder(dynamicCall1) + + // Transfer the corresponding amounts to each recipient, except the last one + const dynamicCall2 = EvmDynamicCallBuilder.forChain(chainId).addUser(MIMIC_PUBLIC_SMART_ACCOUNT) + + for (let i = 0; i < allocations.length - 1; i++) { + const recipient = allocations[i].recipient + dynamicCall2.addCall(tokenOut, TRANSFER_SELECTOR, [ + EvmDynamicArg.literal([new EvmEncodeParam('address', recipient.toString())], false), // to + EvmDynamicArg.variable(1, i, false), // value (dynamicCall1 sub 'i' result) + ]) + } + + builder.addOperationBuilder(dynamicCall2) + + // Get the remaining balance + const dynamicCall3 = EvmDynamicCallBuilder.forChain(chainId).addUser(MIMIC_PUBLIC_SMART_ACCOUNT) + dynamicCall3.addCall(tokenOut, BALANCE_OF_SELECTOR, [ + EvmDynamicArg.literal([new EvmEncodeParam('address', MIMIC_PUBLIC_SMART_ACCOUNT.toString())], false), // account + ]) + + builder.addOperationBuilder(dynamicCall3) + + // Transfer the remaining balance to the last recipient + const lastRecipient = allocations[allocations.length - 1].recipient + const dynamicCall4 = EvmDynamicCallBuilder.forChain(chainId).addUser(MIMIC_PUBLIC_SMART_ACCOUNT) + dynamicCall4.addCall(tokenOut, TRANSFER_SELECTOR, [ + EvmDynamicArg.literal([new EvmEncodeParam('address', lastRecipient.toString())], false), // to + EvmDynamicArg.variable(3, 0, false), // value (dynamicCall3 result) + ]) + + builder.addOperationBuilder(dynamicCall4) + + return builder +} diff --git a/packages/lib-ts/tests/helpers/swapAndSplit.spec.ts b/packages/lib-ts/tests/helpers/swapAndSplit.spec.ts new file mode 100644 index 00000000..94232133 --- /dev/null +++ b/packages/lib-ts/tests/helpers/swapAndSplit.spec.ts @@ -0,0 +1,137 @@ +import { buildSwapAndSplit } from '../../src/helpers' +import { MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS } from '../../src/helpers/constants' +import { Allocation } from '../../src/helpers/swapAndSplit' +import { EvmDynamicCall, OperationType, Swap } from '../../src/intents' +import { ERC20Token, TokenAmount } from '../../src/tokens' +import { Address } from '../../src/types' +import { randomSettler, setContext, setEvmEncode } from '../helpers' + +const chainId = 1 +const user = Address.fromString('0x0000000000000000000000000000000000000001') +const recipient1 = Address.fromString('0x0000000000000000000000000000000000000002') +const recipient2 = Address.fromString('0x0000000000000000000000000000000000000003') +const recipient3 = Address.fromString('0x0000000000000000000000000000000000000004') +const tokenIn = ERC20Token.fromAddress( + Address.fromString('0x0000000000000000000000000000000000000010'), + chainId, + 6, + 'USDC' +) +const tokenOut = ERC20Token.fromAddress( + Address.fromString('0x0000000000000000000000000000000000000020'), + chainId, + 18, + 'DAI' +) +const amountIn = TokenAmount.fromStringDecimal(tokenIn, '10') +const minAmountOut = TokenAmount.fromStringDecimal(tokenOut, '100') + +describe('buildSwapAndSplit', () => { + beforeEach(() => { + setContext(1, 1, user.toString(), [randomSettler(chainId)], 'trigger-123') + + setEvmEncode('uint256', '0', '0x1000') // Dynamic variable 0 + setEvmEncode('uint256', '1', '0x1001') // Dynamic variable 1 + setEvmEncode('uint256', '3', '0x1003') // Dynamic variable 3 + + setEvmEncode('uint16', '9050', '0x9050') + setEvmEncode('uint16', '125', '0x0125') + + setEvmEncode('address', MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS, '0x2000') + setEvmEncode('address', recipient1.toString(), '0x2001') + setEvmEncode('address', recipient2.toString(), '0x2002') + setEvmEncode('address', recipient3.toString(), '0x2003') + }) + + it('creates the intent properly', () => { + const intent = buildSwapAndSplit(chainId, amountIn, minAmountOut, [ + new Allocation(recipient1, 9050), + new Allocation(recipient2, 125), + new Allocation(recipient3, 825), + ]).build() + + expect(intent.operations.length).toBe(5) + expect(intent.operations[0].opType).toBe(OperationType.Swap) + expect(intent.operations[1].opType).toBe(OperationType.EvmDynamicCall) + expect(intent.operations[2].opType).toBe(OperationType.EvmDynamicCall) + expect(intent.operations[3].opType).toBe(OperationType.EvmDynamicCall) + expect(intent.operations[4].opType).toBe(OperationType.EvmDynamicCall) + + const swap = changetype(intent.operations[0]) + expect(swap.sourceChain).toBe(chainId) + expect(swap.destinationChain).toBe(chainId) + expect(swap.tokensIn[0].token).toBe(tokenIn.address.toString()) + expect(swap.tokensIn[0].amount).toBe(amountIn.amount.toString()) + expect(swap.tokensOut[0].token).toBe(tokenOut.address.toString()) + expect(swap.tokensOut[0].minAmount).toBe(minAmountOut.amount.toString()) + expect(swap.user).toBe(user.toString()) + + const pctCall = changetype(intent.operations[1]) + expect(pctCall.calls.length).toBe(2) + + expect(pctCall.calls[0].selector).toBe('0xe7032021') + expect(pctCall.calls[0].arguments[0].data).toBe('0x1000') + expect(pctCall.calls[0].arguments[1].data).toBe('0x9050') + + expect(pctCall.calls[1].selector).toBe('0xe7032021') + expect(pctCall.calls[1].arguments[0].data).toBe('0x1000') + expect(pctCall.calls[1].arguments[1].data).toBe('0x0125') + + const transferCall = changetype(intent.operations[2]) + expect(transferCall.calls.length).toBe(2) + + expect(transferCall.calls[0].selector).toBe('0xa9059cbb') + expect(transferCall.calls[0].arguments[0].data).toBe('0x2001') + expect(transferCall.calls[0].arguments[1].data).toBe('0x1001') + + expect(transferCall.calls[1].selector).toBe('0xa9059cbb') + expect(transferCall.calls[1].arguments[0].data).toBe('0x2002') + expect(transferCall.calls[1].arguments[1].data).toBe('0x1001') + + const balanceOfCall = changetype(intent.operations[3]) + expect(balanceOfCall.calls.length).toBe(1) + + expect(balanceOfCall.calls[0].selector).toBe('0x70a08231') + expect(balanceOfCall.calls[0].arguments[0].data).toBe('0x2000') + + const finalTransferCall = changetype(intent.operations[4]) + expect(finalTransferCall.calls.length).toBe(1) + + expect(finalTransferCall.calls[0].selector).toBe('0xa9059cbb') + expect(finalTransferCall.calls[0].arguments[0].data).toBe('0x2003') + expect(finalTransferCall.calls[0].arguments[1].data).toBe('0x1003') + }) + + it('throws when there is less than two allocations', () => { + expect(() => { + buildSwapAndSplit(chainId, amountIn, minAmountOut, []) + }).toThrow('More than 1 allocation is needed') + + expect(() => { + buildSwapAndSplit(chainId, amountIn, minAmountOut, [new Allocation(recipient1, 10_000)]) + }).toThrow('More than 1 allocation is needed') + }) + + it('throws when allocations do not add up to 100%', () => { + expect(() => { + const allocations = [new Allocation(recipient1, 9999), new Allocation(recipient2, 0)] + + buildSwapAndSplit(chainId, amountIn, minAmountOut, allocations) + }).toThrow('Total allocation percentage must be 10_000 bps') + + expect(() => { + const allocations = [new Allocation(recipient1, 9999), new Allocation(recipient2, 2)] + + buildSwapAndSplit(chainId, amountIn, minAmountOut, allocations) + }).toThrow('Total allocation percentage must be 10_000 bps') + }) + + it('throws when output token is native', () => { + expect(() => { + const nativeAmountOut = TokenAmount.fromStringDecimal(ERC20Token.native(chainId), '100') + const allocations = [new Allocation(recipient1, 50), new Allocation(recipient2, 9950)] + + buildSwapAndSplit(chainId, amountIn, nativeAmountOut, allocations) + }).toThrow('Output token cannot be native') + }) +})