From 1da56ad5fbb6ab14aa3de67353a46ac3f653817d Mon Sep 17 00:00:00 2001 From: John Whiles Date: Tue, 28 Jul 2026 14:46:22 +0100 Subject: [PATCH 1/5] 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 c78b800f438..66ce78eda52 100644 --- a/packages/money-account-utils/CHANGELOG.md +++ b/packages/money-account-utils/CHANGELOG.md @@ -18,6 +18,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 64939ed5320..816ddc8671e 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.4.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 7634a83cf68..2a294e9fd0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7934,6 +7934,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.4.0" "@metamask/utils": "npm:^11.11.0" From 06e4e246d5b07bc7e4ea6e023ef06620774d27a6 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Tue, 28 Jul 2026 14:48:00 +0100 Subject: [PATCH 2/5] 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 66ce78eda52..f6b58072821 100644 --- a/packages/money-account-utils/CHANGELOG.md +++ b/packages/money-account-utils/CHANGELOG.md @@ -18,7 +18,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 7b31571e1880b3b3402383f1e5ad6998fc3b8d38 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Thu, 30 Jul 2026 16:05:36 +0100 Subject: [PATCH 3/5] 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 816ddc8671e..b8f64091628 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 0a6e4e32180de3d911209f6bb8f062aff8ced4ec Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 31 Jul 2026 10:32:36 +0100 Subject: [PATCH 4/5] 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 | 16 +- 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, 164 insertions(+), 91 deletions(-) diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md index f6b58072821..7c0fe688a4e 100644 --- a/packages/money-account-utils/CHANGELOG.md +++ b/packages/money-account-utils/CHANGELOG.md @@ -7,8 +7,19 @@ 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)) - Bump `@metamask/transaction-controller` from `^69.3.0` to `^69.4.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) ## [1.0.0] @@ -18,11 +29,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, }, From 832c497ee3925f1064d0a390290c34eebe47a519 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 3 Aug 2026 10:02:01 +0100 Subject: [PATCH 5/5] fix(money-account-utils): add a withdraw placeholder builder, reject zero amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #9680: a zero-amount withdraw encodes `withdraw(mUSD, 0, 0, moneyAccount)`, which the teller rejects for redeeming no shares. Unlike the deposit path — which got a dedicated placeholder builder that omits calldata — the withdraw path had no way to express "targets resolved, no amount yet" other than the `amount === 0n` sentinel. Adds `buildMoneyAccountWithdrawPlaceholderBatch({ chainId, tellerAddress })`, mirroring the deposit placeholder: synchronous, no provider, no accountant address, no recipient, no money account address. Both encoding builders now throw on a zero amount, before any vault read, so neither can produce calldata that is valid and submittable but cannot succeed. That removes the sentinel, the two dead parameters it implied, and the zero-amount branches in both builders. Note on the reported symptom: this does not change whether a placeholder batch simulates cleanly. `generateEIP7702BatchTransaction` maps absent calldata to `'0x'`, so the deposit placeholder encodes empty calls to the token and teller, and with an atomic batch those revert too. The value here is that the illegal state becomes unrepresentable, not that a simulation starts passing. Consumers must guard a zero amount before calling the encoding builders — for a cleared amount field there is nothing to re-encode. Co-Authored-By: Claude Opus 5 (1M context) --- packages/money-account-utils/CHANGELOG.md | 6 +- packages/money-account-utils/src/index.ts | 3 + .../src/transactions.test.ts | 66 ++++++++-- .../money-account-utils/src/transactions.ts | 122 ++++++++++++++---- 4 files changed, 156 insertions(+), 41 deletions(-) diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md index 7c0fe688a4e..7a25b37aaef 100644 --- a/packages/money-account-utils/CHANGELOG.md +++ b/packages/money-account-utils/CHANGELOG.md @@ -12,10 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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` + - Both take an `@ethersproject` `Provider` for their read calls, and both throw on a zero amount rather than encoding a call that cannot succeed — a zero-share redemption is rejected by the teller, and a zero-amount deposit mints nothing + - `buildMoneyAccountDepositPlaceholderBatch` and `buildMoneyAccountWithdrawPlaceholderBatch` resolve their call targets and types without calldata, for placeholder batches that MetaMask Pay re-encodes once the user picks an amount. They perform no vault reads, so they are synchronous and take 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 + - Supporting exports: `applySlippage`, `getSharesForWithdrawal`, `getMoneyAccountDepositAssetAddress`, `TELLER_ABI`, and the `MoneyAccountTxParams`, `MoneyAccountPlaceholderTxParams`, `MoneyAccountDepositBatchResult`, `MoneyAccountDepositPlaceholderBatchResult`, `MoneyAccountWithdrawBatchResult`, `MoneyAccountWithdrawPlaceholderBatchResult`, `BuildMoneyAccountDepositBatchOptions`, `BuildMoneyAccountDepositPlaceholderBatchOptions`, `BuildMoneyAccountWithdrawBatchOptions`, `BuildMoneyAccountWithdrawPlaceholderBatchOptions` types ### Changed diff --git a/packages/money-account-utils/src/index.ts b/packages/money-account-utils/src/index.ts index 0767504a898..cb74ffbacee 100644 --- a/packages/money-account-utils/src/index.ts +++ b/packages/money-account-utils/src/index.ts @@ -17,6 +17,7 @@ export { buildMoneyAccountDepositBatch, buildMoneyAccountDepositPlaceholderBatch, buildMoneyAccountWithdrawBatch, + buildMoneyAccountWithdrawPlaceholderBatch, getMoneyAccountDepositAssetAddress, getMoneyAccountDepositAssetId, getSharesForWithdrawal, @@ -25,9 +26,11 @@ export type { BuildMoneyAccountDepositBatchOptions, BuildMoneyAccountDepositPlaceholderBatchOptions, BuildMoneyAccountWithdrawBatchOptions, + BuildMoneyAccountWithdrawPlaceholderBatchOptions, MoneyAccountDepositBatchResult, MoneyAccountDepositPlaceholderBatchResult, MoneyAccountPlaceholderTxParams, MoneyAccountTxParams, MoneyAccountWithdrawBatchResult, + MoneyAccountWithdrawPlaceholderBatchResult, } from './transactions.js'; diff --git a/packages/money-account-utils/src/transactions.test.ts b/packages/money-account-utils/src/transactions.test.ts index 9a792d8c124..52f96b96a2c 100644 --- a/packages/money-account-utils/src/transactions.test.ts +++ b/packages/money-account-utils/src/transactions.test.ts @@ -11,6 +11,7 @@ import { buildMoneyAccountDepositBatch, buildMoneyAccountDepositPlaceholderBatch, buildMoneyAccountWithdrawBatch, + buildMoneyAccountWithdrawPlaceholderBatch, getMoneyAccountDepositAssetAddress, getMoneyAccountDepositAssetId, getSharesForWithdrawal, @@ -345,14 +346,15 @@ describe('buildMoneyAccountDepositBatch', () => { 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) }), + it('rejects a zero amount instead of encoding a deposit that mints nothing', async () => { + await expect( + buildMoneyAccountDepositBatch(depositArgs({ amount: BigInt(0) })), + ).rejects.toThrow( + 'Cannot encode a zero-amount Money Account vault call — use buildMoneyAccountDepositPlaceholderBatch for placeholder batches', ); + // Rejected before any I/O, so a placeholder caller pays for no vault read. 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 () => { @@ -455,15 +457,17 @@ describe('buildMoneyAccountWithdrawBatch', () => { expect(getRate).toHaveBeenCalledTimes(1); }); - it('skips the rate read for a zero amount (placeholder batch)', async () => { - const result = await buildMoneyAccountWithdrawBatch( - withdrawArgs({ amount: BigInt(0) }), + it('rejects a zero amount instead of encoding a zero-share redemption', async () => { + // `withdraw(mUSD, 0, 0, moneyAccount)` is valid, submittable calldata that + // the teller rejects for redeeming no shares. + await expect( + buildMoneyAccountWithdrawBatch(withdrawArgs({ amount: BigInt(0) })), + ).rejects.toThrow( + 'Cannot encode a zero-amount Money Account vault call — use buildMoneyAccountWithdrawPlaceholderBatch for placeholder batches', ); + // Rejected before any I/O, so a placeholder caller pays for no vault read. 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 () => { @@ -508,3 +512,43 @@ describe('buildMoneyAccountWithdrawBatch', () => { ).rejects.toThrow('RPC down'); }); }); + +describe('buildMoneyAccountWithdrawPlaceholderBatch', () => { + beforeEach(mockVaultContracts); + + it('returns the withdraw and transfer targets and types without calldata', () => { + const result = buildMoneyAccountWithdrawPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(result.withdrawTx.type).toBe(TransactionType.moneyAccountWithdraw); + expect(result.withdrawTx.params.to).toBe(TELLER); + expect(result.withdrawTx.params.value).toBe('0x0'); + expect(result.withdrawTx.params).not.toHaveProperty('data'); + + expect(result.transferTx.type).toBe(TransactionType.tokenMethodTransfer); + expect(result.transferTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.transferTx.params.value).toBe('0x0'); + expect(result.transferTx.params).not.toHaveProperty('data'); + }); + + it('performs no vault reads', () => { + buildMoneyAccountWithdrawPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(getRate).not.toHaveBeenCalled(); + expect(MockContract).not.toHaveBeenCalled(); + }); + + it('throws for a chain mUSD is not deployed on', () => { + expect(() => + buildMoneyAccountWithdrawPlaceholderBatch({ + chainId: UNSUPPORTED_CHAIN_ID, + tellerAddress: TELLER, + }), + ).toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); +}); diff --git a/packages/money-account-utils/src/transactions.ts b/packages/money-account-utils/src/transactions.ts index 8f9c225b1b8..45e11542177 100644 --- a/packages/money-account-utils/src/transactions.ts +++ b/packages/money-account-utils/src/transactions.ts @@ -240,6 +240,26 @@ export type BuildMoneyAccountDepositPlaceholderBatchOptions = { tellerAddress: Hex; }; +/** + * Guards the encoding builders against a zero amount. + * + * A zero-amount vault call encodes calldata that is structurally valid and + * submittable but cannot succeed — the teller rejects a zero-share redemption, + * and a zero-amount deposit mints nothing. Callers that need a batch before the + * user has picked an amount want a placeholder builder instead, which resolves + * the call targets without calldata. + * + * @param amount - The amount the caller asked to encode. + * @param builderName - Name of the placeholder builder to point the caller at. + */ +function assertNonZeroAmount(amount: bigint, builderName: string): void { + if (amount === 0n) { + throw new Error( + `Cannot encode a zero-amount Money Account vault call — use ${builderName} for placeholder batches`, + ); + } +} + /** * Builds the approve + deposit transaction pair for a Money Account deposit. * @@ -250,10 +270,11 @@ export type BuildMoneyAccountDepositPlaceholderBatchOptions = { * * For placeholder batches with no amount yet, use * {@link buildMoneyAccountDepositPlaceholderBatch} instead — it needs neither a - * provider nor the vault read. + * provider nor the vault read. Throws on a zero amount rather than encoding a + * deposit that mints nothing. * * @param options - Options bag. - * @param options.amount - Deposit amount in mUSD base units. + * @param options.amount - Deposit amount in mUSD base units. Must be non-zero. * @param options.chainId - Chain the deposit happens on. * @param options.boringVault - Address of the boring vault. * @param options.tellerAddress - Address of the teller contract. @@ -271,22 +292,20 @@ export async function buildMoneyAccountDepositBatch({ lensAddress, provider, }: BuildMoneyAccountDepositBatchOptions): Promise { + assertNonZeroAmount(amount, 'buildMoneyAccountDepositPlaceholderBatch'); + 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, - }), - ); + const minimumMint = applySlippage( + await getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, + }), + ); return { approveTx: { @@ -410,6 +429,9 @@ export type MoneyAccountWithdrawBatchResult = MoneyAccountBatchResult< 'withdrawTx' | 'transferTx' >; +export type MoneyAccountWithdrawPlaceholderBatchResult = + MoneyAccountPlaceholderBatchResult<'withdrawTx' | 'transferTx'>; + export type BuildMoneyAccountWithdrawBatchOptions = { amount: bigint; chainId: Hex; @@ -430,11 +452,14 @@ export type BuildMoneyAccountWithdrawBatchOptions = { * 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. + * For placeholder batches with no amount yet, use + * {@link buildMoneyAccountWithdrawPlaceholderBatch} instead — it needs neither a + * provider nor the rate read. Throws on a zero amount rather than encoding a + * zero-share redemption, which the teller rejects. * * @param options - Options bag. - * @param options.amount - Withdrawal amount in mUSD base units. + * @param options.amount - Withdrawal amount in mUSD base units. Must be + * non-zero. * @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. @@ -453,15 +478,14 @@ export async function buildMoneyAccountWithdrawBatch({ recipient, provider, }: BuildMoneyAccountWithdrawBatchOptions): Promise { + assertNonZeroAmount(amount, 'buildMoneyAccountWithdrawPlaceholderBatch'); + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); - const shareAmount = - amount === 0n - ? 0n - : getSharesForWithdrawal( - amount, - await getVaultRate({ accountantAddress, provider }), - ); + const shareAmount = 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 @@ -471,7 +495,7 @@ export async function buildMoneyAccountWithdrawBatch({ // 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 minimumAssets = amount - 1n; const withdrawData = buildWithdrawData( musdAddress, shareAmount, @@ -499,3 +523,47 @@ export async function buildMoneyAccountWithdrawBatch({ }, }; } + +export type BuildMoneyAccountWithdrawPlaceholderBatchOptions = { + chainId: Hex; + tellerAddress: Hex; +}; + +/** + * Builds the withdraw + transfer pair for a Money Account withdrawal *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 neither a provider, an accountant address, a recipient, nor + * the money account address. + * + * Mirrors {@link buildMoneyAccountDepositPlaceholderBatch}. Encoding a + * zero-amount withdrawal instead would produce a valid, submittable + * `withdraw(mUSD, 0, 0, moneyAccount)` that the teller rejects for redeeming no + * shares — this builder makes that state unrepresentable. + * + * @param options - Options bag. + * @param options.chainId - Chain the withdrawal happens on. + * @param options.tellerAddress - Address of the teller contract. + * @returns The withdraw and transfer transaction targets, keyed by name. + */ +export function buildMoneyAccountWithdrawPlaceholderBatch({ + chainId, + tellerAddress, +}: BuildMoneyAccountWithdrawPlaceholderBatchOptions): MoneyAccountWithdrawPlaceholderBatchResult { + return { + withdrawTx: { + params: { + to: tellerAddress, + value: '0x0', + }, + type: TransactionType.moneyAccountWithdraw, + }, + transferTx: { + params: { + to: getMoneyAccountDepositAssetAddress(chainId), + value: '0x0', + }, + type: TransactionType.tokenMethodTransfer, + }, + }; +}