Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fiery-frogs-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@mimicprotocol/lib-ts": patch
"@mimicprotocol/cli": patch
"@mimicprotocol/test-ts": patch
---

Add swapAndSplit function
3 changes: 2 additions & 1 deletion packages/lib-ts/src/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions packages/lib-ts/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export * from './BorshDeserializer'
export * from './constants'
export * from './serialize'
export * from './strings'
export * from './swapAndSplit'
export { Consensus, Math }
105 changes: 105 additions & 0 deletions packages/lib-ts/src/helpers/swapAndSplit.ts
Original file line number Diff line number Diff line change
@@ -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')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (totalPctBps !== MAX_PCT_BPS) throw new Error('Total allocation percentage must be 10_000 bps')
if (totalPctBps !== MAX_PCT_BPS) throw new Error('Total allocation percentage must add up to 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully get why "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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems to be constant, maybe it can be abstracted into a variable to improve its readability

EvmDynamicArg.literal([new EvmEncodeParam('uint16', pctBps.toString())], false), // percent bps
])
}

builder.addOperationBuilder(dynamicCall1)

// Transfer the corresponding amounts to each recipient, except the last one

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, why "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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we can use a variable inside the loop to improve its readability, similar to recipient

])
}

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

])

builder.addOperationBuilder(dynamicCall4)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would name dynamicCall1, 2, 3, and 4 properly


return builder
}
137 changes: 137 additions & 0 deletions packages/lib-ts/tests/helpers/swapAndSplit.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Swap>(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<EvmDynamicCall>(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<EvmDynamicCall>(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<EvmDynamicCall>(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<EvmDynamicCall>(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')
})
})
Loading