From beb1c04f87b43494296d1d87f05f691337f9d6eb Mon Sep 17 00:00:00 2001 From: John Whiles Date: Tue, 28 Jul 2026 14:46:22 +0100 Subject: [PATCH 1/4] feat(money-account-utils): add money account transaction batch builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the Money Account deposit and withdrawal batch builders from metamask-mobile (`app/components/UI/Money/utils/moneyAccountTransactions.ts`) so both clients encode the vault calls identically. The client-resident wrappers around these builders stay in mobile: they read vault config, provider and recipient from Redux/Engine singletons, which do not belong in a shared package. Everything below that — ABIs, calldata encoding, slippage and share arithmetic, and the two `previewDeposit`/`getRate` reads — moves here, keeping the existing signatures so call sites only change their import path. Co-Authored-By: Claude Opus 5 (1M context) --- packages/money-account-utils/CHANGELOG.md | 5 + packages/money-account-utils/package.json | 3 + packages/money-account-utils/src/index.ts | 16 + .../src/transactions.test.ts | 509 ++++++++++++++++++ .../money-account-utils/src/transactions.ts | 439 +++++++++++++++ yarn.lock | 3 + 6 files changed, 975 insertions(+) create mode 100644 packages/money-account-utils/src/transactions.test.ts create mode 100644 packages/money-account-utils/src/transactions.ts diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md index 4dd69910376..685834adcc7 100644 --- a/packages/money-account-utils/CHANGELOG.md +++ b/packages/money-account-utils/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add mUSD token constants and guards, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) - Constants: `MUSD_TOKEN` (without client icon assets), `MUSD_DECIMALS`, `MUSD_TOKEN_ADDRESS`, `MUSD_TOKEN_ADDRESS_BY_CHAIN`, `MUSD_TOKEN_ASSET_ID_BY_CHAIN`, `MUSD_CURRENCY`, `MUSD_MONEY_ACCOUNT_CHAIN_IDS` - Guards: `isMusdToken`, `isMusdTokenOnChain`, `isMusdOnMoneyAccountChain` +- Add Money Account transaction batch builders, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - `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 placeholder batches + - Supporting exports: `applySlippage`, `getSharesForWithdrawal`, `getMoneyAccountDepositAssetAddress`, `getMoneyAccountDepositAssetId`, `TELLER_ABI`, and the `MoneyAccountTxParams`, `MoneyAccountDepositBatchResult`, `MoneyAccountWithdrawBatchResult`, `BuildMoneyAccountDepositBatchOptions`, `BuildMoneyAccountWithdrawBatchOptions` types - Add `getTokenDisplaySymbol`, ported from MetaMask Mobile, which canonicalises the registry symbol of the mUSD token to its branded casing (`MUSD` → `mUSD`) and passes all other symbols through unchanged ([#9397](https://github.com/MetaMask/core/pull/9397)) [Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-utils@1.0.0...HEAD diff --git a/packages/money-account-utils/package.json b/packages/money-account-utils/package.json index e74e7f4c84d..626d74a0129 100644 --- a/packages/money-account-utils/package.json +++ b/packages/money-account-utils/package.json @@ -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..529b3c6df4d 100644 --- a/packages/money-account-utils/src/index.ts +++ b/packages/money-account-utils/src/index.ts @@ -11,3 +11,19 @@ export { isMusdTokenOnChain, isMusdOnMoneyAccountChain, } from './musd.js'; +export { + TELLER_ABI, + applySlippage, + buildMoneyAccountDepositBatch, + buildMoneyAccountWithdrawBatch, + getMoneyAccountDepositAssetAddress, + getMoneyAccountDepositAssetId, + getSharesForWithdrawal, +} from './transactions.js'; +export type { + BuildMoneyAccountDepositBatchOptions, + BuildMoneyAccountWithdrawBatchOptions, + MoneyAccountDepositBatchResult, + MoneyAccountTxParams, + MoneyAccountWithdrawBatchResult, +} from './transactions.js'; 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..bbe423b7504 --- /dev/null +++ b/packages/money-account-utils/src/transactions.test.ts @@ -0,0 +1,509 @@ +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, + 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('falls back to the Monad asset id for an unknown chain', () => { + expect(getMoneyAccountDepositAssetId(UNSUPPORTED_CHAIN_ID)).toBe( + MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD], + ); + }); + + it('falls back to the Monad asset id when chainId is undefined', () => { + expect(getMoneyAccountDepositAssetId(undefined)).toBe( + MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD], + ); + }); +}); + +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('returns undefined data fields when initialiseWithoutData is true', async () => { + const result = await buildMoneyAccountDepositBatch( + depositArgs({ amount: BigInt(0), initialiseWithoutData: true }), + ); + + expect(result.approveTx.params.data).toBeUndefined(); + expect(result.depositTx.params.data).toBeUndefined(); + expect(result.approveTx.type).toBe(TransactionType.tokenMethodApprove); + expect(result.depositTx.type).toBe(TransactionType.moneyAccountDeposit); + expect(result.approveTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.depositTx.params.to).toBe(TELLER); + }); + + it('still resolves minimumMint for non-zero amounts when initialiseWithoutData is true', async () => { + previewDeposit.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountDepositBatch( + depositArgs({ initialiseWithoutData: true }), + ); + + expect(previewDeposit).toHaveBeenCalledTimes(1); + expect(result.approveTx.params.data).toBeUndefined(); + expect(result.depositTx.params.data).toBeUndefined(); + }); + + it('builds calldata when initialiseWithoutData is explicitly false', async () => { + previewDeposit.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountDepositBatch( + depositArgs({ initialiseWithoutData: false }), + ); + + expect(result.approveTx.params.data).toBeDefined(); + expect(result.depositTx.params.data).toBeDefined(); + }); + + 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('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..0d2fedfe9f4 --- /dev/null +++ b/packages/money-account-utils/src/transactions.ts @@ -0,0 +1,439 @@ +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 { 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; +}; + +/** + * 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 +>; + +// -- 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`. + * + * Money Account is Monad-only today, so an unknown or undefined `chainId` falls + * back to the Monad mUSD asset id rather than throwing — the entry-point gate + * that consumes this should still resolve against the asset the deposit flow + * actually targets. + * + * @param chainId - The chain ID to get the deposit asset id for. + * @returns The CAIP-19 asset id of the deposit asset for the given chain ID. + */ +export function getMoneyAccountDepositAssetId(chainId?: Hex): CaipAssetType { + return (MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId as Hex] ?? + MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD]) as CaipAssetType; +} + +export type MoneyAccountDepositBatchResult = MoneyAccountBatchResult< + 'approveTx' | 'depositTx' +>; + +export type BuildMoneyAccountDepositBatchOptions = { + amount: bigint; + chainId: Hex; + boringVault: string; + tellerAddress: string; + accountantAddress: string; + lensAddress: string; + provider: Provider; + initialiseWithoutData?: boolean; +}; + +/** + * 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. + * + * @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. + * @param options.initialiseWithoutData - When true, returns the transaction + * targets and types without calldata, for placeholder batches that Pay + * re-encodes once the user picks an amount. + * @returns The approve and deposit transactions, keyed by name. + */ +export async function buildMoneyAccountDepositBatch({ + amount, + chainId, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + provider, + initialiseWithoutData = false, +}: BuildMoneyAccountDepositBatchOptions): Promise { + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); + + // Skip the RPC call for zero-amount placeholder batches (e.g. initial deposit submission). + const minimumMint = + amount === 0n + ? 0n + : applySlippage( + await getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, + }), + ); + + const approveData = initialiseWithoutData + ? undefined + : buildApproveData(boringVault, amount); + const depositData = initialiseWithoutData + ? undefined + : buildDepositData(musdAddress, amount, minimumMint); + + return { + approveTx: { + params: { + to: musdAddress, + data: approveData, + value: '0x0' as Hex, + }, + type: TransactionType.tokenMethodApprove, + }, + depositTx: { + params: { + to: tellerAddress as Hex, + data: depositData, + value: '0x0' as Hex, + }, + 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 USDC here first. */ + moneyAccountAddress: Hex; + /** Address of the user's selected EVM account — receives the USDC 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 — USDC lands on the money account. + * 4. Encodes `transfer(recipient, amount)` on the USDC contract — moves the exact requested USDC 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 === BigInt(0) + ? BigInt(0) + : 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' as Hex, + }, + type: TransactionType.moneyAccountWithdraw, + }, + transferTx: { + params: { + to: musdAddress, + data: transferData, + value: '0x0' as Hex, + }, + 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" From 004a3a87c0ce3931f0646c683e541527d4bbf9cb Mon Sep 17 00:00:00 2001 From: John Whiles Date: Tue, 28 Jul 2026 14:48:00 +0100 Subject: [PATCH 2/4] docs(money-account-utils): point changelog entry at the builders PR Co-Authored-By: Claude Opus 5 (1M context) --- packages/money-account-utils/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md index 685834adcc7..fc36db5d237 100644 --- a/packages/money-account-utils/CHANGELOG.md +++ b/packages/money-account-utils/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add mUSD token constants and guards, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) - Constants: `MUSD_TOKEN` (without client icon assets), `MUSD_DECIMALS`, `MUSD_TOKEN_ADDRESS`, `MUSD_TOKEN_ADDRESS_BY_CHAIN`, `MUSD_TOKEN_ASSET_ID_BY_CHAIN`, `MUSD_CURRENCY`, `MUSD_MONEY_ACCOUNT_CHAIN_IDS` - Guards: `isMusdToken`, `isMusdTokenOnChain`, `isMusdOnMoneyAccountChain` -- Add Money Account transaction batch builders, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) +- 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 placeholder batches From bc5a36b8e0a2445a9733c8cb1c332c117f512ebd Mon Sep 17 00:00:00 2001 From: John Whiles Date: Thu, 30 Jul 2026 16:05:36 +0100 Subject: [PATCH 3/4] docs(money-account-utils): describe what the package actually contains The description predated the rescope: activity parsing and classification were never extracted, and the vault transaction builders now are. Co-Authored-By: Claude Opus 5 (1M context) --- packages/money-account-utils/README.md | 2 +- packages/money-account-utils/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 626d74a0129..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" From a42e8d0d293ba234d2b72ef10399eab459b3936d Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 31 Jul 2026 10:32:36 +0100 Subject: [PATCH 4/4] fix(money-account-utils): tighten the transaction builder API Address review feedback on the ported vault builders: - Replace the `initialiseWithoutData` flag with a dedicated `buildMoneyAccountDepositPlaceholderBatch`. The flag path still awaited `previewDeposit` and discarded the result, and required five vault addresses it never used; the new builder is synchronous, takes only `chainId` and `tellerAddress`, and performs no vault reads. - Make `MoneyAccountTxParams.params.data` required, with the no-calldata case as `MoneyAccountPlaceholderTxParams`, so callers of the encoding builders no longer have to narrow an optional field. - Return `CaipAssetType | undefined` from `getMoneyAccountDepositAssetId` rather than silently defaulting to Monad, so an unsupported chain stays distinguishable. Clients apply their own default at the call site. - Type `MUSD_TOKEN_ASSET_ID_BY_CHAIN` values as `CaipAssetType` and the deposit options' addresses as `Hex`, removing the internal casts. - Fix withdraw comments describing the redeemed asset as USDC; the builders encode mUSD throughout. Also reconcile the changelog with the released 1.0.0 of the package. Co-Authored-By: Claude Opus 5 (1M context) --- packages/money-account-utils/CHANGELOG.md | 19 ++- packages/money-account-utils/src/index.ts | 4 + packages/money-account-utils/src/musd.ts | 4 +- .../src/transactions.test.ts | 89 +++++------ .../money-account-utils/src/transactions.ts | 142 +++++++++++++----- 5 files changed, 167 insertions(+), 91 deletions(-) diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md index fc36db5d237..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 @@ -14,11 +28,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add mUSD token constants and guards, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) - Constants: `MUSD_TOKEN` (without client icon assets), `MUSD_DECIMALS`, `MUSD_TOKEN_ADDRESS`, `MUSD_TOKEN_ADDRESS_BY_CHAIN`, `MUSD_TOKEN_ASSET_ID_BY_CHAIN`, `MUSD_CURRENCY`, `MUSD_MONEY_ACCOUNT_CHAIN_IDS` - Guards: `isMusdToken`, `isMusdTokenOnChain`, `isMusdOnMoneyAccountChain` -- 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 placeholder batches - - Supporting exports: `applySlippage`, `getSharesForWithdrawal`, `getMoneyAccountDepositAssetAddress`, `getMoneyAccountDepositAssetId`, `TELLER_ABI`, and the `MoneyAccountTxParams`, `MoneyAccountDepositBatchResult`, `MoneyAccountWithdrawBatchResult`, `BuildMoneyAccountDepositBatchOptions`, `BuildMoneyAccountWithdrawBatchOptions` types - Add `getTokenDisplaySymbol`, ported from MetaMask Mobile, which canonicalises the registry symbol of the mUSD token to its branded casing (`MUSD` → `mUSD`) and passes all other symbols through unchanged ([#9397](https://github.com/MetaMask/core/pull/9397)) [Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-utils@1.0.0...HEAD diff --git a/packages/money-account-utils/src/index.ts b/packages/money-account-utils/src/index.ts index 529b3c6df4d..0767504a898 100644 --- a/packages/money-account-utils/src/index.ts +++ b/packages/money-account-utils/src/index.ts @@ -15,6 +15,7 @@ export { TELLER_ABI, applySlippage, buildMoneyAccountDepositBatch, + buildMoneyAccountDepositPlaceholderBatch, buildMoneyAccountWithdrawBatch, getMoneyAccountDepositAssetAddress, getMoneyAccountDepositAssetId, @@ -22,8 +23,11 @@ export { } 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 index bbe423b7504..9a792d8c124 100644 --- a/packages/money-account-utils/src/transactions.test.ts +++ b/packages/money-account-utils/src/transactions.test.ts @@ -9,6 +9,7 @@ import { MUSD_TOKEN_ADDRESS, MUSD_TOKEN_ASSET_ID_BY_CHAIN } from './musd.js'; import { applySlippage, buildMoneyAccountDepositBatch, + buildMoneyAccountDepositPlaceholderBatch, buildMoneyAccountWithdrawBatch, getMoneyAccountDepositAssetAddress, getMoneyAccountDepositAssetId, @@ -280,16 +281,12 @@ describe('getMoneyAccountDepositAssetId', () => { ); }); - it('falls back to the Monad asset id for an unknown chain', () => { - expect(getMoneyAccountDepositAssetId(UNSUPPORTED_CHAIN_ID)).toBe( - MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD], - ); + it('returns undefined for a chain mUSD is not deployed on', () => { + expect(getMoneyAccountDepositAssetId(UNSUPPORTED_CHAIN_ID)).toBeUndefined(); }); - it('falls back to the Monad asset id when chainId is undefined', () => { - expect(getMoneyAccountDepositAssetId(undefined)).toBe( - MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD], - ); + it('returns undefined when chainId is undefined', () => { + expect(getMoneyAccountDepositAssetId(undefined)).toBeUndefined(); }); }); @@ -358,42 +355,6 @@ describe('buildMoneyAccountDepositBatch', () => { expect(BigInt(decoded.minimumMint.toString())).toBe(BigInt(0)); }); - it('returns undefined data fields when initialiseWithoutData is true', async () => { - const result = await buildMoneyAccountDepositBatch( - depositArgs({ amount: BigInt(0), initialiseWithoutData: true }), - ); - - expect(result.approveTx.params.data).toBeUndefined(); - expect(result.depositTx.params.data).toBeUndefined(); - expect(result.approveTx.type).toBe(TransactionType.tokenMethodApprove); - expect(result.depositTx.type).toBe(TransactionType.moneyAccountDeposit); - expect(result.approveTx.params.to).toBe(MUSD_TOKEN_ADDRESS); - expect(result.depositTx.params.to).toBe(TELLER); - }); - - it('still resolves minimumMint for non-zero amounts when initialiseWithoutData is true', async () => { - previewDeposit.mockResolvedValue(BigInt(1_000_000)); - - const result = await buildMoneyAccountDepositBatch( - depositArgs({ initialiseWithoutData: true }), - ); - - expect(previewDeposit).toHaveBeenCalledTimes(1); - expect(result.approveTx.params.data).toBeUndefined(); - expect(result.depositTx.params.data).toBeUndefined(); - }); - - it('builds calldata when initialiseWithoutData is explicitly false', async () => { - previewDeposit.mockResolvedValue(BigInt(1_000_000)); - - const result = await buildMoneyAccountDepositBatch( - depositArgs({ initialiseWithoutData: false }), - ); - - expect(result.approveTx.params.data).toBeDefined(); - expect(result.depositTx.params.data).toBeDefined(); - }); - it('throws for a chain mUSD is not deployed on', async () => { await expect( buildMoneyAccountDepositBatch( @@ -411,6 +372,46 @@ describe('buildMoneyAccountDepositBatch', () => { }); }); +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); diff --git a/packages/money-account-utils/src/transactions.ts b/packages/money-account-utils/src/transactions.ts index 0d2fedfe9f4..8f9c225b1b8 100644 --- a/packages/money-account-utils/src/transactions.ts +++ b/packages/money-account-utils/src/transactions.ts @@ -1,7 +1,7 @@ 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 { TransactionType } from '@metamask/transaction-controller'; import type { CaipAssetType, Hex } from '@metamask/utils'; import { @@ -52,7 +52,21 @@ export function applySlippage(value: bigint): bigint { export type MoneyAccountTxParams = { params: { to: Hex; - data?: 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; @@ -68,6 +82,15 @@ type MoneyAccountBatchResult = Record< MoneyAccountTxParams >; +/** + * Result shape for the placeholder variants of the batch builders. Mirrors + * {@link MoneyAccountBatchResult} but without calldata. + */ +type MoneyAccountPlaceholderBatchResult = Record< + TxKey, + MoneyAccountPlaceholderTxParams +>; + // -- Deposit helpers ------------------------------------------------------- /** @@ -176,32 +199,45 @@ export function getMoneyAccountDepositAssetAddress(chainId: Hex): Hex { * 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`. * - * Money Account is Monad-only today, so an unknown or undefined `chainId` falls - * back to the Monad mUSD asset id rather than throwing — the entry-point gate - * that consumes this should still resolve against the asset the deposit flow - * actually targets. + * 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 for the given chain ID. + * @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 { - return (MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId as Hex] ?? - MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD]) as CaipAssetType; +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: string; - tellerAddress: string; - accountantAddress: string; - lensAddress: string; + boringVault: Hex; + tellerAddress: Hex; + accountantAddress: Hex; + lensAddress: Hex; provider: Provider; - initialiseWithoutData?: boolean; +}; + +export type BuildMoneyAccountDepositPlaceholderBatchOptions = { + chainId: Hex; + tellerAddress: Hex; }; /** @@ -212,6 +248,10 @@ export type BuildMoneyAccountDepositBatchOptions = { * 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. @@ -220,9 +260,6 @@ export type BuildMoneyAccountDepositBatchOptions = { * @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. - * @param options.initialiseWithoutData - When true, returns the transaction - * targets and types without calldata, for placeholder batches that Pay - * re-encodes once the user picks an amount. * @returns The approve and deposit transactions, keyed by name. */ export async function buildMoneyAccountDepositBatch({ @@ -233,11 +270,10 @@ export async function buildMoneyAccountDepositBatch({ accountantAddress, lensAddress, provider, - initialiseWithoutData = false, }: BuildMoneyAccountDepositBatchOptions): Promise { const musdAddress = getMoneyAccountDepositAssetAddress(chainId); - // Skip the RPC call for zero-amount placeholder batches (e.g. initial deposit submission). + // Nothing to preview for a zero-amount deposit, so skip the RPC call. const minimumMint = amount === 0n ? 0n @@ -252,27 +288,53 @@ export async function buildMoneyAccountDepositBatch({ }), ); - const approveData = initialiseWithoutData - ? undefined - : buildApproveData(boringVault, amount); - const depositData = initialiseWithoutData - ? undefined - : buildDepositData(musdAddress, amount, minimumMint); - return { approveTx: { params: { to: musdAddress, - data: approveData, - value: '0x0' as Hex, + data: buildApproveData(boringVault, amount), + value: '0x0', }, type: TransactionType.tokenMethodApprove, }, depositTx: { params: { - to: tellerAddress as Hex, - data: depositData, - value: '0x0' as Hex, + 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, }, @@ -353,9 +415,9 @@ export type BuildMoneyAccountWithdrawBatchOptions = { chainId: Hex; tellerAddress: Hex; accountantAddress: Hex; - /** Address of the money account — vault sends USDC here first. */ + /** Address of the money account — vault sends the redeemed mUSD here first. */ moneyAccountAddress: Hex; - /** Address of the user's selected EVM account — receives the USDC transfer. */ + /** Address of the user's selected EVM account — receives the mUSD transfer. */ recipient: Hex; provider: Provider; }; @@ -365,8 +427,8 @@ export type BuildMoneyAccountWithdrawBatchOptions = { * * 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 — USDC lands on the money account. - * 4. Encodes `transfer(recipient, amount)` on the USDC contract — moves the exact requested USDC from the money account to the user's selected EVM account. + * 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. @@ -394,8 +456,8 @@ export async function buildMoneyAccountWithdrawBatch({ const musdAddress = getMoneyAccountDepositAssetAddress(chainId); const shareAmount = - amount === BigInt(0) - ? BigInt(0) + amount === 0n + ? 0n : getSharesForWithdrawal( amount, await getVaultRate({ accountantAddress, provider }), @@ -423,7 +485,7 @@ export async function buildMoneyAccountWithdrawBatch({ params: { to: tellerAddress, data: withdrawData, - value: '0x0' as Hex, + value: '0x0', }, type: TransactionType.moneyAccountWithdraw, }, @@ -431,7 +493,7 @@ export async function buildMoneyAccountWithdrawBatch({ params: { to: musdAddress, data: transferData, - value: '0x0' as Hex, + value: '0x0', }, type: TransactionType.tokenMethodTransfer, },