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..e8209a12 100644 --- a/packages/lib-ts/src/helpers/constants.ts +++ b/packages/lib-ts/src/helpers/constants.ts @@ -7,9 +7,12 @@ export const MAX_UINT256_HEX = '0xffffffffffffffffffffffffffffffffffffffffffffff export const STANDARD_DECIMALS: u8 = 18 +export const ONE_HUNDRED_PCT_BPS: u16 = 10_000 + export enum ListType { AllowList = 0, DenyList = 1, } -export const MIMIC_HELPER_ADDRESS = '0x5cf82cbed1110fc2f75b3413d53abac492931804' +export const MIMIC_HELPER_ADDRESS = '0x2ef71e27560874b932ef1cf9e95d340595a92f44' +export const MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS = '0x2518928fdf52319cc5a9a797354cf159cd2221bb' 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..55da6d19 --- /dev/null +++ b/packages/lib-ts/src/helpers/swapAndSplit.ts @@ -0,0 +1,121 @@ +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, ONE_HUNDRED_PCT_BPS } 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 MIN_ALLOCATIONS: i32 = 2 + +const SWAP_OP_INDEX: u32 = 0 +const SWAP_OP_SUB_INDEX: u32 = 0 +const PCT_OP_INDEX: u32 = 1 +const BALANCE_OF_OP_INDEX: u32 = 3 +const BALANCE_OF_OP_SUB_INDEX: u32 = 0 + +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. + * Each recipient receives the percentage of the output token specified in the allocations array. + * The last recipient receives its specified percentage plus any remaining balance caused by rounding. + * @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. + * @param smartAccount The smart account that receives the swap output and executes the transfers (optional). + * If not provided, the Mimic public smart account 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, + smartAccount: Address = MIMIC_PUBLIC_SMART_ACCOUNT +): IntentBuilder { + if (allocations.length < MIN_ALLOCATIONS) throw new Error(`At least ${MIN_ALLOCATIONS} allocations are required`) + + let totalPctBps: u32 = 0 + for (let i = 0; i < allocations.length; i++) totalPctBps += allocations[i].pctBps + if (totalPctBps !== ONE_HUNDRED_PCT_BPS) { + throw new Error(`Total allocation percentage must add up to ${ONE_HUNDRED_PCT_BPS} 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) + .addTokenInFromTokenAmount(amountIn) + .addTokenOutFromTokenAmount(minAmountOut, smartAccount) + + if (user) swap.addUser(user) + + builder.addOperationBuilder(swap) + + // Calculate the corresponding amount for each allocation, except the last one, which will receive the remaining balance + const pctDynamicCall = EvmDynamicCallBuilder.forChain(chainId).addUser(smartAccount) + const swapOutput = EvmDynamicArg.variable(SWAP_OP_INDEX, SWAP_OP_SUB_INDEX, false) + + for (let i = 0; i < allocations.length - 1; i++) { + const pctBps = allocations[i].pctBps + pctDynamicCall.addCall(MIMIC_HELPER, PCT_SELECTOR, [ + swapOutput, // amount + EvmDynamicArg.literal([new EvmEncodeParam('uint16', pctBps.toString())], false), // percent bps + ]) + } + + builder.addOperationBuilder(pctDynamicCall) + + // Transfer the corresponding amounts to each recipient, except the last one, which will receive the remaining balance + const transferDynamicCall = EvmDynamicCallBuilder.forChain(chainId).addUser(smartAccount) + + for (let i = 0; i < allocations.length - 1; i++) { + const recipient = allocations[i].recipient + const pctOutput = EvmDynamicArg.variable(PCT_OP_INDEX, i, false) + transferDynamicCall.addCall(tokenOut, TRANSFER_SELECTOR, [ + EvmDynamicArg.literal([new EvmEncodeParam('address', recipient.toString())], false), // to + pctOutput, // value + ]) + } + + builder.addOperationBuilder(transferDynamicCall) + + // Get the remaining balance + const balanceOfDynamicCall = EvmDynamicCallBuilder.forChain(chainId).addUser(smartAccount) + balanceOfDynamicCall.addCall(tokenOut, BALANCE_OF_SELECTOR, [ + EvmDynamicArg.literal([new EvmEncodeParam('address', smartAccount.toString())], false), // account + ]) + + builder.addOperationBuilder(balanceOfDynamicCall) + + // Transfer the remaining balance to the last recipient + const lastTransferDynamicCall = EvmDynamicCallBuilder.forChain(chainId).addUser(smartAccount) + const lastRecipient = allocations[allocations.length - 1].recipient + const balanceOfOutput = EvmDynamicArg.variable(BALANCE_OF_OP_INDEX, BALANCE_OF_OP_SUB_INDEX, false) + + lastTransferDynamicCall.addCall(tokenOut, TRANSFER_SELECTOR, [ + EvmDynamicArg.literal([new EvmEncodeParam('address', lastRecipient.toString())], false), // to + balanceOfOutput, // value + ]) + + builder.addOperationBuilder(lastTransferDynamicCall) + + return builder +} diff --git a/packages/lib-ts/src/tokens/TokenAmount.ts b/packages/lib-ts/src/tokens/TokenAmount.ts index d292c745..f4a7f01c 100644 --- a/packages/lib-ts/src/tokens/TokenAmount.ts +++ b/packages/lib-ts/src/tokens/TokenAmount.ts @@ -1,11 +1,12 @@ import { environment } from '../environment' +import { ONE_HUNDRED_PCT_BPS } from '../helpers/constants' import { BigInt, JSON, Result } from '../types' import { BlockchainToken } from './BlockchainToken' import { SerializableToken, Token } from './Token' import { USD } from './USD' -const BPS_SCALE = BigInt.fromI32(10_000) +const BPS_SCALE = BigInt.fromI32(ONE_HUNDRED_PCT_BPS) /** * Represents an amount of a specific token, combining the token metadata with a quantity. 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..d5070fe9 --- /dev/null +++ b/packages/lib-ts/tests/helpers/swapAndSplit.spec.ts @@ -0,0 +1,170 @@ +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 anotherUser = Address.fromString('0x0000000000000000000000000000000000000005') +const anotherSmartAccount = Address.fromString('0x0000000000000000000000000000000000000006') +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') + setEvmEncode('address', anotherSmartAccount.toString(), '0x2006') + }) + + 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.user).toBe(user.toString()) + 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.tokensOut[0].recipient).toBe(MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS.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('uses the given user when defined', () => { + const allocations = [new Allocation(recipient1, 9050), new Allocation(recipient2, 950)] + const intent = buildSwapAndSplit(chainId, amountIn, minAmountOut, allocations, anotherUser).build() + + const swap = changetype(intent.operations[0]) + expect(swap.user).toBe(anotherUser.toString()) + expect(swap.tokensOut[0].recipient).toBe(MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS) + + for (let i = 1; i < intent.operations.length; i++) { + expect(intent.operations[i].user).toBe(MIMIC_PUBLIC_SMART_ACCOUNT_ADDRESS) + } + }) + + it('uses the given smart account when defined', () => { + const allocations = [new Allocation(recipient1, 9050), new Allocation(recipient2, 950)] + const intent = buildSwapAndSplit(chainId, amountIn, minAmountOut, allocations, null, anotherSmartAccount).build() + + const swap = changetype(intent.operations[0]) + expect(swap.tokensOut[0].recipient).toBe(anotherSmartAccount.toString()) + expect(swap.user).toBe(user.toString()) + + for (let i = 1; i < intent.operations.length; i++) { + expect(intent.operations[i].user).toBe(anotherSmartAccount.toString()) + } + + const balanceOfCall = changetype(intent.operations[3]) + expect(balanceOfCall.calls[0].arguments[0].data).toBe('0x2006') + }) + + it('throws when there is less than two allocations', () => { + expect(() => { + buildSwapAndSplit(chainId, amountIn, minAmountOut, []) + }).toThrow() + + expect(() => { + buildSwapAndSplit(chainId, amountIn, minAmountOut, [new Allocation(recipient1, 10_000)]) + }).toThrow() + }) + + 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() + + expect(() => { + const allocations = [new Allocation(recipient1, 9999), new Allocation(recipient2, 2)] + + buildSwapAndSplit(chainId, amountIn, minAmountOut, allocations) + }).toThrow() + }) + + 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() + }) +})