diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md index 4dd69910376..7c963dcae5f 100644 --- a/packages/money-account-utils/CHANGELOG.md +++ b/packages/money-account-utils/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Money Account transaction batch builders, ported from MetaMask Mobile ([#9680](https://github.com/MetaMask/core/pull/9680)) + - `buildMoneyAccountDepositBatch` builds the approve + deposit call pair, deriving `minimumMint` from the vault lens' `previewDeposit` less a 0.2% slippage tolerance + - `buildMoneyAccountWithdrawBatch` builds the withdraw + transfer call pair, converting the asset amount to vault shares at the accountant's current rate + - Both take an `@ethersproject` `Provider` for their read calls and skip those reads for zero-amount batches + - `buildMoneyAccountDepositPlaceholderBatch` resolves the deposit call targets and types without calldata, for placeholder batches that MetaMask Pay re-encodes once the user picks an amount. It performs no vault reads, so it is synchronous and takes only `chainId` and `tellerAddress` + - `getMoneyAccountDepositAssetId` returns the CAIP-19 asset id of the deposit asset for a chain, or `undefined` if mUSD is not deployed there. Clients that want Money Account's Monad-only default apply it at the call site + - Supporting exports: `applySlippage`, `getSharesForWithdrawal`, `getMoneyAccountDepositAssetAddress`, `TELLER_ABI`, and the `MoneyAccountTxParams`, `MoneyAccountPlaceholderTxParams`, `MoneyAccountDepositBatchResult`, `MoneyAccountDepositPlaceholderBatchResult`, `MoneyAccountWithdrawBatchResult`, `BuildMoneyAccountDepositBatchOptions`, `BuildMoneyAccountDepositPlaceholderBatchOptions`, `BuildMoneyAccountWithdrawBatchOptions` types + +### Changed + +- Type the values of `MUSD_TOKEN_ASSET_ID_BY_CHAIN` as `CaipAssetType` rather than `string` ([#9680](https://github.com/MetaMask/core/pull/9680)) + ## [1.0.0] ### Added diff --git a/packages/money-account-utils/README.md b/packages/money-account-utils/README.md index 98244e89040..8b92aeb484a 100644 --- a/packages/money-account-utils/README.md +++ b/packages/money-account-utils/README.md @@ -1,6 +1,6 @@ # `@metamask/money-account-utils` -Shared money account utilities: activity parsing, classification, and mUSD constants. +Shared money account utilities: mUSD constants and vault transaction builders. ## Installation diff --git a/packages/money-account-utils/package.json b/packages/money-account-utils/package.json index e74e7f4c84d..cc1de2a6b2b 100644 --- a/packages/money-account-utils/package.json +++ b/packages/money-account-utils/package.json @@ -1,7 +1,7 @@ { "name": "@metamask/money-account-utils", "version": "1.0.0", - "description": "Shared money account utilities: activity parsing, classification, and mUSD constants", + "description": "Shared money account utilities: mUSD constants and vault transaction builders", "keywords": [ "Ethereum", "MetaMask" @@ -55,6 +55,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", "@metamask/transaction-controller": "^69.3.0", "@metamask/utils": "^11.11.0" }, diff --git a/packages/money-account-utils/src/index.ts b/packages/money-account-utils/src/index.ts index 2f1787a24c2..0767504a898 100644 --- a/packages/money-account-utils/src/index.ts +++ b/packages/money-account-utils/src/index.ts @@ -11,3 +11,23 @@ export { isMusdTokenOnChain, isMusdOnMoneyAccountChain, } from './musd.js'; +export { + TELLER_ABI, + applySlippage, + buildMoneyAccountDepositBatch, + buildMoneyAccountDepositPlaceholderBatch, + buildMoneyAccountWithdrawBatch, + getMoneyAccountDepositAssetAddress, + getMoneyAccountDepositAssetId, + getSharesForWithdrawal, +} from './transactions.js'; +export type { + BuildMoneyAccountDepositBatchOptions, + BuildMoneyAccountDepositPlaceholderBatchOptions, + BuildMoneyAccountWithdrawBatchOptions, + MoneyAccountDepositBatchResult, + MoneyAccountDepositPlaceholderBatchResult, + MoneyAccountPlaceholderTxParams, + MoneyAccountTxParams, + MoneyAccountWithdrawBatchResult, +} from './transactions.js'; diff --git a/packages/money-account-utils/src/musd.ts b/packages/money-account-utils/src/musd.ts index 523056bf36f..4ce87a32695 100644 --- a/packages/money-account-utils/src/musd.ts +++ b/packages/money-account-utils/src/musd.ts @@ -1,5 +1,5 @@ import { CHAIN_IDS } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; /** * The mUSD (MetaMask USD) token, minus any client-specific presentation @@ -44,7 +44,7 @@ export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { /** * The CAIP-19 asset id of the mUSD token on each chain where it is deployed. */ -export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { +export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { [CHAIN_IDS.MAINNET]: 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', [CHAIN_IDS.LINEA_MAINNET]: diff --git a/packages/money-account-utils/src/transactions.test.ts b/packages/money-account-utils/src/transactions.test.ts new file mode 100644 index 00000000000..9a792d8c124 --- /dev/null +++ b/packages/money-account-utils/src/transactions.test.ts @@ -0,0 +1,510 @@ +import type { Result } from '@ethersproject/abi'; +import { Interface } from '@ethersproject/abi'; +import type { Provider } from '@ethersproject/abstract-provider'; +import { Contract } from '@ethersproject/contracts'; +import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { MUSD_TOKEN_ADDRESS, MUSD_TOKEN_ASSET_ID_BY_CHAIN } from './musd.js'; +import { + applySlippage, + buildMoneyAccountDepositBatch, + buildMoneyAccountDepositPlaceholderBatch, + buildMoneyAccountWithdrawBatch, + getMoneyAccountDepositAssetAddress, + getMoneyAccountDepositAssetId, + getSharesForWithdrawal, + TELLER_ABI, +} from './transactions.js'; + +jest.mock('@ethersproject/contracts'); + +const MockContract = Contract as jest.MockedClass; + +const CHAIN_ID = CHAIN_IDS.MONAD; +const UNSUPPORTED_CHAIN_ID = '0xdead' as Hex; +const BORING_VAULT = '0xB5F07d769dD60fE54c97dd53101181073DDf21b2' as Hex; +const TELLER = '0x86821F179eaD9F0b3C79b2f8deF0227eEBFDc9f9' as Hex; +const ACCOUNTANT = '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173' as Hex; +const LENS = '0x846a7832022350434B5cC006d07cc9c782469660' as Hex; +const MONEY_ACCOUNT_ADDRESS = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; +const RECIPIENT_ADDRESS = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Hex; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const PROVIDER = {} as Provider; + +const ERC20_INTERFACE = new Interface([ + 'function approve(address spender, uint256 amount)', + 'function transfer(address to, uint256 amount)', +]); +const TELLER_INTERFACE = new Interface(TELLER_ABI); + +const previewDeposit = jest.fn(); +const getRate = jest.fn(); + +/** + * Builds the arguments for a deposit batch, with defaults for every vault + * address so each test only states what it cares about. + * + * @param overrides - Argument overrides. + * @returns The deposit batch arguments. + */ +function depositArgs( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + amount: BigInt(1_000_000), + chainId: CHAIN_ID, + boringVault: BORING_VAULT, + tellerAddress: TELLER, + accountantAddress: ACCOUNTANT, + lensAddress: LENS, + provider: PROVIDER, + ...overrides, + }; +} + +/** + * Builds the arguments for a withdraw batch, with defaults for every vault + * address so each test only states what it cares about. + * + * @param overrides - Argument overrides. + * @returns The withdraw batch arguments. + */ +function withdrawArgs( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + amount: BigInt(1_000_000), + chainId: CHAIN_ID, + tellerAddress: TELLER, + accountantAddress: ACCOUNTANT, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + recipient: RECIPIENT_ADDRESS, + provider: PROVIDER, + ...overrides, + }; +} + +/** + * Asserts two addresses are equal, ignoring case. Decoded calldata comes back + * EIP-55 checksummed regardless of the casing that was encoded. + * + * @param actual - The address to check. + * @param expected - The address it should equal. + */ +function expectSameAddress(actual: string, expected: string): void { + expect(actual.toLowerCase()).toBe(expected.toLowerCase()); +} + +/** + * Decodes the arguments of an encoded teller call. + * + * @param name - The teller function that was encoded. + * @param data - The encoded calldata. + * @returns The decoded arguments. + */ +function decodeTellerCall( + name: 'deposit' | 'withdraw', + data: Hex | undefined, +): Result { + if (!data) { + throw new Error(`Expected ${name} calldata`); + } + return TELLER_INTERFACE.decodeFunctionData(name, data); +} + +/** + * Decodes the arguments of an encoded ERC-20 call. + * + * @param name - The ERC-20 function that was encoded. + * @param data - The encoded calldata. + * @returns The decoded arguments. + */ +function decodeErc20Call( + name: 'approve' | 'transfer', + data: Hex | undefined, +): Result { + if (!data) { + throw new Error(`Expected ${name} calldata`); + } + return ERC20_INTERFACE.decodeFunctionData(name, data); +} + +/** + * Points the vault contract reads at the local mocks. The builders construct + * contracts by ABI, so the mock dispatches on which function the given ABI + * declares. + */ +function mockVaultContracts(): void { + jest.clearAllMocks(); + MockContract.mockImplementation( + (_address: string, abi: unknown) => + (JSON.stringify(abi).includes('previewDeposit') + ? { previewDeposit } + : { getRate }) as unknown as Contract, + ); +} + +describe('applySlippage', () => { + it('applies 0.2% slippage to a round value', () => { + expect(applySlippage(BigInt(1000))).toBe(BigInt(998)); + }); + + it('applies 0.2% slippage with integer truncation', () => { + expect(applySlippage(BigInt(1))).toBe(BigInt(0)); + }); + + it('applies 0.2% slippage to a large value', () => { + const amount = BigInt('1000000000000000000'); + expect(applySlippage(amount)).toBe((amount * BigInt(998)) / BigInt(1000)); + }); + + it('returns 0 for 0 input', () => { + expect(applySlippage(BigInt(0))).toBe(BigInt(0)); + }); +}); + +describe('getSharesForWithdrawal', () => { + const SHARE_SCALAR = BigInt(1_000_000); + + it('converts amount to shares at 1:1 rate (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(1_000_000), BigInt(1_000_000))).toBe( + BigInt(1_000_000), + ); + }); + + it('scales down when rate is higher than 1:1 (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(1_000_000), BigInt(2_000_000))).toBe( + BigInt(500_000), + ); + }); + + it('scales up when rate is lower than 1:1 (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(2_000_000), BigInt(1_000_000))).toBe( + BigInt(2_000_000), + ); + }); + + it('uses ceiling division — rounds up when remainder exists', () => { + // floor(1_000_000 * 1_000_000 / 3_000_000) = 333_333, so ceiling is 333_334. + const amount = BigInt(1_000_000); + const rate = BigInt(3_000_000); + expect((amount * SHARE_SCALAR) / rate).toBe(BigInt(333_333)); + expect(getSharesForWithdrawal(amount, rate)).toBe(BigInt(333_334)); + }); + + it('reproduces the exact reported scenario — $1.96 at rate ~1,000,094', () => { + // This was the failing case: floor division gave 1,959,815 shares, and the + // contract's mulDivDown produced 1,959,999 assetsOut < 1,960,000 + // minimumAssets. + const amount = BigInt(1_960_000); // $1.96 in 6 decimals + const rate = BigInt(1_000_094); + + expect((amount * SHARE_SCALAR) / rate).toBe(BigInt(1_959_815)); // old buggy value + + const ceilShares = getSharesForWithdrawal(amount, rate); + expect(ceilShares).toBe(BigInt(1_959_816)); // fixed: one more share + + // Verify: contract mulDivDown(ceilShares * rate / SCALAR) >= amount + expect((ceilShares * rate) / SHARE_SCALAR).toBeGreaterThanOrEqual(amount); + }); + + it('reproduces the reported $1.00 scenario — was passing by luck', () => { + const amount = BigInt(1_000_000); + const rate = BigInt(1_000_094); + + const floorShares = (amount * SHARE_SCALAR) / rate; + const ceilShares = getSharesForWithdrawal(amount, rate); + + expect(ceilShares).toBeGreaterThanOrEqual(floorShares); + expect((ceilShares * rate) / SHARE_SCALAR).toBeGreaterThanOrEqual(amount); + }); + + it('handles large amounts with ceiling division', () => { + const amount = BigInt('1000000000000'); // $1M in 6 decimals + const rate = BigInt('1500000'); + const result = getSharesForWithdrawal(amount, rate); + const floorResult = (amount * SHARE_SCALAR) / rate; + + expect(result).toBeGreaterThanOrEqual(floorResult); + // And at most one more than floor. + expect(result - floorResult).toBeLessThanOrEqual(BigInt(1)); + }); + + it('ceiling division equals floor when division is exact', () => { + const amount = BigInt(2_000_000); + const rate = BigInt(500_000); + expect(getSharesForWithdrawal(amount, rate)).toBe( + (amount * SHARE_SCALAR) / rate, + ); + }); + + it('returns 0 for zero amount', () => { + expect(getSharesForWithdrawal(BigInt(0), BigInt(1_000_000))).toBe( + BigInt(0), + ); + }); + + it('guarantees assetsOut >= amount across rates near 1:1', () => { + const amount = BigInt(1_960_000); + for (let rawRate = 999_900; rawRate <= 1_000_200; rawRate++) { + const rate = BigInt(rawRate); + const shares = getSharesForWithdrawal(amount, rate); + // Simulate the contract's mulDivDown. + expect((shares * rate) / SHARE_SCALAR).toBeGreaterThanOrEqual(amount); + } + }); +}); + +describe('getMoneyAccountDepositAssetAddress', () => { + it('returns the mUSD address for a chain mUSD is deployed on', () => { + expect(getMoneyAccountDepositAssetAddress(CHAIN_ID)).toBe( + MUSD_TOKEN_ADDRESS, + ); + }); + + it('throws for a chain mUSD is not deployed on', () => { + expect(() => + getMoneyAccountDepositAssetAddress(UNSUPPORTED_CHAIN_ID), + ).toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); +}); + +describe('getMoneyAccountDepositAssetId', () => { + it('returns the mapped asset id for a known chain', () => { + expect(getMoneyAccountDepositAssetId(CHAIN_IDS.MONAD)).toBe( + MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD], + ); + expect(getMoneyAccountDepositAssetId(CHAIN_IDS.MAINNET)).toBe( + MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MAINNET], + ); + }); + + it('returns undefined for a chain mUSD is not deployed on', () => { + expect(getMoneyAccountDepositAssetId(UNSUPPORTED_CHAIN_ID)).toBeUndefined(); + }); + + it('returns undefined when chainId is undefined', () => { + expect(getMoneyAccountDepositAssetId(undefined)).toBeUndefined(); + }); +}); + +describe('buildMoneyAccountDepositBatch', () => { + beforeEach(mockVaultContracts); + + it('returns approve and deposit transactions with the expected targets and types', async () => { + previewDeposit.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountDepositBatch(depositArgs()); + + expect(result.approveTx.type).toBe(TransactionType.tokenMethodApprove); + expect(result.approveTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.approveTx.params.value).toBe('0x0'); + + expect(result.depositTx.type).toBe(TransactionType.moneyAccountDeposit); + expect(result.depositTx.params.to).toBe(TELLER); + expect(result.depositTx.params.value).toBe('0x0'); + }); + + it('encodes an approval of the deposit amount for the boring vault', async () => { + previewDeposit.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountDepositBatch( + depositArgs({ amount: BigInt(500_000) }), + ); + + const decoded = decodeErc20Call('approve', result.approveTx.params.data); + expectSameAddress(decoded.spender, BORING_VAULT); + expect(BigInt(decoded.amount.toString())).toBe(BigInt(500_000)); + }); + + it('calls previewDeposit with the deposit asset, amount and vault addresses', async () => { + previewDeposit.mockResolvedValue(BigInt(500_000)); + + await buildMoneyAccountDepositBatch(depositArgs()); + + expect(previewDeposit).toHaveBeenCalledWith( + MUSD_TOKEN_ADDRESS, + '1000000', + BORING_VAULT, + ACCOUNTANT, + ); + }); + + it('derives minimumMint from the previewed shares less slippage', async () => { + const shares = BigInt(1_000_000); + previewDeposit.mockResolvedValue(shares); + + const result = await buildMoneyAccountDepositBatch(depositArgs()); + + const decoded = decodeTellerCall('deposit', result.depositTx.params.data); + expectSameAddress(decoded.depositAsset, MUSD_TOKEN_ADDRESS); + expect(BigInt(decoded.depositAmount.toString())).toBe(BigInt(1_000_000)); + expect(BigInt(decoded.minimumMint.toString())).toBe(applySlippage(shares)); + expectSameAddress(decoded.referralAddress, ZERO_ADDRESS); + }); + + it('skips the previewDeposit read and mints nothing for a zero amount', async () => { + const result = await buildMoneyAccountDepositBatch( + depositArgs({ amount: BigInt(0) }), + ); + + expect(previewDeposit).not.toHaveBeenCalled(); + const decoded = decodeTellerCall('deposit', result.depositTx.params.data); + expect(BigInt(decoded.minimumMint.toString())).toBe(BigInt(0)); + }); + + it('throws for a chain mUSD is not deployed on', async () => { + await expect( + buildMoneyAccountDepositBatch( + depositArgs({ chainId: UNSUPPORTED_CHAIN_ID }), + ), + ).rejects.toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); + + it('propagates previewDeposit failures', async () => { + previewDeposit.mockRejectedValue(new Error('RPC down')); + + await expect(buildMoneyAccountDepositBatch(depositArgs())).rejects.toThrow( + 'RPC down', + ); + }); +}); + +describe('buildMoneyAccountDepositPlaceholderBatch', () => { + beforeEach(mockVaultContracts); + + it('returns the approve and deposit targets and types without calldata', () => { + const result = buildMoneyAccountDepositPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(result.approveTx.type).toBe(TransactionType.tokenMethodApprove); + expect(result.approveTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.approveTx.params.value).toBe('0x0'); + expect(result.approveTx.params).not.toHaveProperty('data'); + + expect(result.depositTx.type).toBe(TransactionType.moneyAccountDeposit); + expect(result.depositTx.params.to).toBe(TELLER); + expect(result.depositTx.params.value).toBe('0x0'); + expect(result.depositTx.params).not.toHaveProperty('data'); + }); + + it('performs no vault reads', () => { + buildMoneyAccountDepositPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(previewDeposit).not.toHaveBeenCalled(); + expect(MockContract).not.toHaveBeenCalled(); + }); + + it('throws for a chain mUSD is not deployed on', () => { + expect(() => + buildMoneyAccountDepositPlaceholderBatch({ + chainId: UNSUPPORTED_CHAIN_ID, + tellerAddress: TELLER, + }), + ).toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); +}); + +describe('buildMoneyAccountWithdrawBatch', () => { + beforeEach(mockVaultContracts); + + it('returns withdraw and transfer transactions with the expected targets and types', async () => { + getRate.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountWithdrawBatch(withdrawArgs()); + + expect(result.withdrawTx.type).toBe(TransactionType.moneyAccountWithdraw); + expect(result.withdrawTx.params.to).toBe(TELLER); + expect(result.withdrawTx.params.value).toBe('0x0'); + + // The transfer targets the mUSD token contract, not the recipient. + expect(result.transferTx.type).toBe(TransactionType.tokenMethodTransfer); + expect(result.transferTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.transferTx.params.value).toBe('0x0'); + }); + + it('redeems shares to the money account and transfers the amount to the recipient', async () => { + getRate.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountWithdrawBatch(withdrawArgs()); + + const withdraw = decodeTellerCall( + 'withdraw', + result.withdrawTx.params.data, + ); + expectSameAddress(withdraw.withdrawAsset, MUSD_TOKEN_ADDRESS); + expectSameAddress(withdraw.to, MONEY_ACCOUNT_ADDRESS); + + const transfer = decodeErc20Call('transfer', result.transferTx.params.data); + expectSameAddress(transfer.to, RECIPIENT_ADDRESS); + expect(BigInt(transfer.amount.toString())).toBe(BigInt(1_000_000)); + }); + + it('reads the vault rate once', async () => { + getRate.mockResolvedValue(BigInt(2_000_000)); + + await buildMoneyAccountWithdrawBatch(withdrawArgs()); + + expect(getRate).toHaveBeenCalledTimes(1); + }); + + it('skips the rate read for a zero amount (placeholder batch)', async () => { + const result = await buildMoneyAccountWithdrawBatch( + withdrawArgs({ amount: BigInt(0) }), + ); + + expect(getRate).not.toHaveBeenCalled(); + const decoded = decodeTellerCall('withdraw', result.withdrawTx.params.data); + expect(BigInt(decoded.shareAmount.toString())).toBe(BigInt(0)); + expect(BigInt(decoded.minimumAssets.toString())).toBe(BigInt(0)); + }); + + it('encodes minimumAssets as amount - 1 for defense-in-depth', async () => { + getRate.mockResolvedValue(BigInt(1_000_000)); + const amount = BigInt(1_960_000); + + const result = await buildMoneyAccountWithdrawBatch( + withdrawArgs({ amount }), + ); + + const decoded = decodeTellerCall('withdraw', result.withdrawTx.params.data); + expect(BigInt(decoded.minimumAssets.toString())).toBe(amount - BigInt(1)); + }); + + it('uses ceiling division for shareAmount in withdraw calldata', async () => { + // A rate that produces a remainder, to verify ceiling division. + getRate.mockResolvedValue(BigInt(1_000_094)); + + const result = await buildMoneyAccountWithdrawBatch( + withdrawArgs({ amount: BigInt(1_960_000) }), + ); + + const decoded = decodeTellerCall('withdraw', result.withdrawTx.params.data); + // Ceiling: (1_960_000 * 1_000_000 + 1_000_094 - 1) / 1_000_094 = 1_959_816. + // Floor division would give 1_959_815. + expect(BigInt(decoded.shareAmount.toString())).toBe(BigInt(1_959_816)); + }); + + it('throws for a chain mUSD is not deployed on', async () => { + await expect( + buildMoneyAccountWithdrawBatch( + withdrawArgs({ chainId: UNSUPPORTED_CHAIN_ID }), + ), + ).rejects.toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); + + it('propagates getRate failures', async () => { + getRate.mockRejectedValue(new Error('RPC down')); + + await expect( + buildMoneyAccountWithdrawBatch(withdrawArgs()), + ).rejects.toThrow('RPC down'); + }); +}); diff --git a/packages/money-account-utils/src/transactions.ts b/packages/money-account-utils/src/transactions.ts new file mode 100644 index 00000000000..8f9c225b1b8 --- /dev/null +++ b/packages/money-account-utils/src/transactions.ts @@ -0,0 +1,501 @@ +import { Interface } from '@ethersproject/abi'; +import type { Provider } from '@ethersproject/abstract-provider'; +import { Contract } from '@ethersproject/contracts'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { CaipAssetType, Hex } from '@metamask/utils'; + +import { + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from './musd.js'; + +const LENS_ABI = [ + 'function previewDeposit(address depositAsset, uint256 depositAmount, address boringVault, address accountant) view returns (uint256 shares)', +]; + +export const TELLER_ABI = [ + 'function deposit(address depositAsset, uint256 depositAmount, uint256 minimumMint, address referralAddress) payable returns (uint256 shares)', + 'function withdraw(address withdrawAsset, uint256 shareAmount, uint256 minimumAssets, address to) returns (uint256 assetsOut)', +]; + +const ACCOUNTANT_ABI = ['function getRate() view returns (uint256 rate)']; + +const ERC20_ABI = [ + 'function approve(address spender, uint256 amount)', + 'function transfer(address to, uint256 amount)', +]; + +/** + * Referral address passed to the teller's `deposit` call. The Money Account + * deposit flow has no referrer, so the zero address is sent explicitly. + */ +const ZERO_ADDRESS: Hex = '0x0000000000000000000000000000000000000000'; + +// -- Shared constants ------------------------------------------------------ + +const SLIPPAGE_NUMERATOR = BigInt(998); +const SLIPPAGE_DENOMINATOR = BigInt(1000); + +/** + * Applies a 0.2% slippage tolerance to a bigint value. + * If this sanity-check causes a revert, no funds are lost — retry with a fresh quote. + * + * @param value - The value to apply the slippage tolerance to. + * @returns The value reduced by the slippage tolerance, truncated to an integer. + */ +export function applySlippage(value: bigint): bigint { + return (value * SLIPPAGE_NUMERATOR) / SLIPPAGE_DENOMINATOR; +} + +// -- Shared types ---------------------------------------------------------- + +export type MoneyAccountTxParams = { + params: { + to: Hex; + data: Hex; + value: Hex; + }; + type: TransactionType; +}; + +/** + * A Money Account call with its target and type resolved but no calldata, for + * placeholder batches that Pay re-encodes once the user picks an amount. + * Distinct from {@link MoneyAccountTxParams} so callers of the encoding + * builders never have to narrow an optional `data`. + */ +export type MoneyAccountPlaceholderTxParams = { + params: { + to: Hex; + value: Hex; + }; + type: TransactionType; +}; + +/** + * Result shape for Money Account transaction batch builders. The string keys + * (e.g. `approveTx`, `withdrawTx`) name each call so callers don't depend on + * positional ordering in `addTransactionBatch.transactions[]`. + */ +type MoneyAccountBatchResult = Record< + TxKey, + MoneyAccountTxParams +>; + +/** + * Result shape for the placeholder variants of the batch builders. Mirrors + * {@link MoneyAccountBatchResult} but without calldata. + */ +type MoneyAccountPlaceholderBatchResult = Record< + TxKey, + MoneyAccountPlaceholderTxParams +>; + +// -- Deposit helpers ------------------------------------------------------- + +/** + * Reads the vault shares a deposit of `amount` would mint, via the lens + * contract's `previewDeposit`. + * + * @param options - Options bag. + * @param options.lensAddress - Address of the vault lens contract. + * @param options.boringVault - Address of the boring vault. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.musdAddress - Address of the mUSD deposit asset. + * @param options.amount - Deposit amount in mUSD base units. + * @param options.provider - Provider used for the read call. + * @returns The expected vault shares. + */ +async function getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, +}: { + lensAddress: string; + boringVault: string; + accountantAddress: string; + musdAddress: string; + amount: bigint; + provider: Provider; +}): Promise { + const lensContract = new Contract(lensAddress, LENS_ABI, provider); + const shares = await lensContract.previewDeposit( + musdAddress, + amount.toString(), + boringVault, + accountantAddress, + ); + return BigInt(shares.toString()); +} + +/** + * Encodes the ERC-20 `approve` call granting the boring vault an allowance. + * + * @param boringVault - Address to approve as spender. + * @param amount - Allowance in mUSD base units. + * @returns The encoded calldata. + */ +function buildApproveData(boringVault: string, amount: bigint): Hex { + const iface = new Interface(ERC20_ABI); + return iface.encodeFunctionData('approve', [ + boringVault, + amount.toString(), + ]) as Hex; +} + +/** + * Encodes an ERC-20 `transfer` call. + * + * @param to - Recipient of the transfer. + * @param amount - Transfer amount in token base units. + * @returns The encoded calldata. + */ +function buildErc20TransferData(to: string, amount: bigint): Hex { + const iface = new Interface(ERC20_ABI); + return iface.encodeFunctionData('transfer', [to, amount.toString()]) as Hex; +} + +/** + * Encodes the teller's `deposit` call. + * + * @param musdAddress - Address of the mUSD deposit asset. + * @param amount - Deposit amount in mUSD base units. + * @param minimumMint - Minimum vault shares the deposit must mint. + * @returns The encoded calldata. + */ +function buildDepositData( + musdAddress: string, + amount: bigint, + minimumMint: bigint, +): Hex { + const iface = new Interface(TELLER_ABI); + return iface.encodeFunctionData('deposit', [ + musdAddress, + amount.toString(), + minimumMint.toString(), + ZERO_ADDRESS, + ]) as Hex; +} + +/** + * Single source of truth for the deposit asset so both calldata encoding + * (`buildMoneyAccountDepositBatch`) and Pay's `requiredAssets` agree. + * + * @param chainId - The chain ID to get the deposit asset address for. + * @returns The deposit asset address for the given chain ID. + */ +export function getMoneyAccountDepositAssetAddress(chainId: Hex): Hex { + const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!musdAddress) { + throw new Error(`mUSD not deployed on chain ${chainId}`); + } + return musdAddress; +} + +/** + * Resolves the CAIP-19 asset id of the Money Account deposit asset (mUSD) for a + * given chain. Pure mapping over `MUSD_TOKEN_ASSET_ID_BY_CHAIN`. + * + * Returns `undefined` for a chain mUSD is not deployed on, so an unsupported + * chain stays distinguishable from a supported one. Clients that want a default + * (e.g. Money Account being Monad-only today) apply it at the call site: + * `getMoneyAccountDepositAssetId(chainId) ?? + * MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD]`. + * + * @param chainId - The chain ID to get the deposit asset id for. + * @returns The CAIP-19 asset id of the deposit asset, or `undefined` if mUSD is + * not deployed on the given chain. + */ +export function getMoneyAccountDepositAssetId( + chainId?: Hex, +): CaipAssetType | undefined { + if (!chainId) { + return undefined; + } + return MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId]; +} + +export type MoneyAccountDepositBatchResult = MoneyAccountBatchResult< + 'approveTx' | 'depositTx' +>; + +export type MoneyAccountDepositPlaceholderBatchResult = + MoneyAccountPlaceholderBatchResult<'approveTx' | 'depositTx'>; + +export type BuildMoneyAccountDepositBatchOptions = { + amount: bigint; + chainId: Hex; + boringVault: Hex; + tellerAddress: Hex; + accountantAddress: Hex; + lensAddress: Hex; + provider: Provider; +}; + +export type BuildMoneyAccountDepositPlaceholderBatchOptions = { + chainId: Hex; + tellerAddress: Hex; +}; + +/** + * Builds the approve + deposit transaction pair for a Money Account deposit. + * + * 1. Calls `previewDeposit` on the lens contract to get expected vault shares. + * 2. Applies a 0.2% slippage tolerance to derive `minimumMint`. + * 3. Encodes ERC-20 `approve(boringVault, amount)` on the mUSD token. + * 4. Encodes `deposit(mUSD, amount, minimumMint, 0x0)` on the teller contract. + * + * For placeholder batches with no amount yet, use + * {@link buildMoneyAccountDepositPlaceholderBatch} instead — it needs neither a + * provider nor the vault read. + * + * @param options - Options bag. + * @param options.amount - Deposit amount in mUSD base units. + * @param options.chainId - Chain the deposit happens on. + * @param options.boringVault - Address of the boring vault. + * @param options.tellerAddress - Address of the teller contract. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.lensAddress - Address of the vault lens contract. + * @param options.provider - Provider used for the `previewDeposit` read. + * @returns The approve and deposit transactions, keyed by name. + */ +export async function buildMoneyAccountDepositBatch({ + amount, + chainId, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + provider, +}: BuildMoneyAccountDepositBatchOptions): Promise { + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); + + // Nothing to preview for a zero-amount deposit, so skip the RPC call. + const minimumMint = + amount === 0n + ? 0n + : applySlippage( + await getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, + }), + ); + + return { + approveTx: { + params: { + to: musdAddress, + data: buildApproveData(boringVault, amount), + value: '0x0', + }, + type: TransactionType.tokenMethodApprove, + }, + depositTx: { + params: { + to: tellerAddress, + data: buildDepositData(musdAddress, amount, minimumMint), + value: '0x0', + }, + type: TransactionType.moneyAccountDeposit, + }, + }; +} + +/** + * Builds the approve + deposit pair for a Money Account deposit *without* + * calldata, for placeholder batches that Pay re-encodes once the user picks an + * amount. Resolves the call targets and types only, so it performs no vault + * reads and needs no provider. + * + * @param options - Options bag. + * @param options.chainId - Chain the deposit happens on. + * @param options.tellerAddress - Address of the teller contract. + * @returns The approve and deposit transaction targets, keyed by name. + */ +export function buildMoneyAccountDepositPlaceholderBatch({ + chainId, + tellerAddress, +}: BuildMoneyAccountDepositPlaceholderBatchOptions): MoneyAccountDepositPlaceholderBatchResult { + return { + approveTx: { + params: { + to: getMoneyAccountDepositAssetAddress(chainId), + value: '0x0', + }, + type: TransactionType.tokenMethodApprove, + }, + depositTx: { + params: { + to: tellerAddress, + value: '0x0', + }, + type: TransactionType.moneyAccountDeposit, + }, + }; +} + +// -- Withdrawal helpers ---------------------------------------------------- + +/** + * Reads the current vault exchange rate from the accountant contract. + * + * @param options - Options bag. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.provider - Provider used for the read call. + * @returns The current vault rate. + */ +async function getVaultRate({ + accountantAddress, + provider, +}: { + accountantAddress: string; + provider: Provider; +}): Promise { + const accountant = new Contract(accountantAddress, ACCOUNTANT_ABI, provider); + const rate = await accountant.getRate(); + return BigInt(rate.toString()); +} + +const SHARE_DECIMALS_SCALAR = BigInt(1_000_000); + +/** + * Converts a USD asset amount (6 decimals) to vault shares given a pre-fetched rate. + * Pure arithmetic — no I/O, safe to call directly inside workflows. + * + * Uses ceiling division so the contract's `mulDivDown(shares × rate / ONE_SHARE)` + * always produces `assetsOut >= minimumAssets`. Floor division caused a double- + * truncation bug where `assetsOut` could land 1 unit below `minimumAssets`, + * reverting with `MinimumAssetsNotMet`. + * + * @param amount - The asset amount in mUSD base units. + * @param rate - The current vault rate. + * @returns The vault shares needed to withdraw `amount`. + */ +export function getSharesForWithdrawal(amount: bigint, rate: bigint): bigint { + return (amount * SHARE_DECIMALS_SCALAR + rate - 1n) / rate; +} + +/** + * Encodes the teller's `withdraw` call. + * + * @param musdAddress - Address of the mUSD withdraw asset. + * @param shareAmount - Vault shares to redeem. + * @param minimumAssets - Minimum assets the redemption must return. + * @param toAddress - Address that receives the redeemed assets. + * @returns The encoded calldata. + */ +function buildWithdrawData( + musdAddress: string, + shareAmount: bigint, + minimumAssets: bigint, + toAddress: string, +): Hex { + const iface = new Interface(TELLER_ABI); + return iface.encodeFunctionData('withdraw', [ + musdAddress, + shareAmount.toString(), + minimumAssets.toString(), + toAddress, + ]) as Hex; +} + +export type MoneyAccountWithdrawBatchResult = MoneyAccountBatchResult< + 'withdrawTx' | 'transferTx' +>; + +export type BuildMoneyAccountWithdrawBatchOptions = { + amount: bigint; + chainId: Hex; + tellerAddress: Hex; + accountantAddress: Hex; + /** Address of the money account — vault sends the redeemed mUSD here first. */ + moneyAccountAddress: Hex; + /** Address of the user's selected EVM account — receives the mUSD transfer. */ + recipient: Hex; + provider: Provider; +}; + +/** + * Builds the two-transaction withdrawal batch for a Money Account withdrawal. + * + * 1. Calls `getRate` on the accountant contract to get the current vault rate. + * 2. Converts the asset amount to vault shares. + * 3. Encodes `withdraw(mUSD, shareAmount, minimumAssets, moneyAccountAddress)` on the teller contract — the redeemed mUSD lands on the money account. + * 4. Encodes `transfer(recipient, amount)` on the mUSD token contract — moves the exact requested amount from the money account to the user's selected EVM account. + * + * When `amount === 0n` the rate fetch is skipped: the caller is encoding a + * placeholder batch that Pay will re-encode once the user picks an amount. + * + * @param options - Options bag. + * @param options.amount - Withdrawal amount in mUSD base units. + * @param options.chainId - Chain the withdrawal happens on. + * @param options.tellerAddress - Address of the teller contract. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.moneyAccountAddress - Money account address; the vault sends + * the redeemed assets here first. + * @param options.recipient - Address that receives the subsequent transfer. + * @param options.provider - Provider used for the `getRate` read. + * @returns The withdraw and transfer transactions, keyed by name. + */ +export async function buildMoneyAccountWithdrawBatch({ + amount, + chainId, + tellerAddress, + accountantAddress, + moneyAccountAddress, + recipient, + provider, +}: BuildMoneyAccountWithdrawBatchOptions): Promise { + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); + + const shareAmount = + amount === 0n + ? 0n + : getSharesForWithdrawal( + amount, + await getVaultRate({ accountantAddress, provider }), + ); + // Allow 1-unit slippage on minimumAssets as defense-in-depth against + // rounding: the contract's mulDivDown can truncate assetsOut by up to + // 1 unit relative to the requested amount. This tolerance is safe + // because ceiling division in getSharesForWithdrawal already guarantees + // assetsOut >= amount; the 1-unit slack here is a second line of + // defense, not a standalone fix. The subsequent ERC-20 transfer uses + // the original `amount`, so the tolerance does not affect how much the + // user receives — it only prevents a spurious revert from the teller's + // MinimumAssetsNotMet check. + const minimumAssets = amount > 0n ? amount - 1n : 0n; + const withdrawData = buildWithdrawData( + musdAddress, + shareAmount, + minimumAssets, + moneyAccountAddress, + ); + const transferData = buildErc20TransferData(recipient, amount); + + return { + withdrawTx: { + params: { + to: tellerAddress, + data: withdrawData, + value: '0x0', + }, + type: TransactionType.moneyAccountWithdraw, + }, + transferTx: { + params: { + to: musdAddress, + data: transferData, + value: '0x0', + }, + type: TransactionType.tokenMethodTransfer, + }, + }; +} diff --git a/yarn.lock b/yarn.lock index 7ca0a363f60..3771cc1476b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7933,6 +7933,9 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-utils@workspace:packages/money-account-utils" dependencies: + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/abstract-provider": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/transaction-controller": "npm:^69.3.0" "@metamask/utils": "npm:^11.11.0"