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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ linkStyle default opacity:0.5
transaction_pay_controller --> gas_fee_controller;
transaction_pay_controller --> keyring_controller;
transaction_pay_controller --> messenger;
transaction_pay_controller --> money_account_utils;
transaction_pay_controller --> network_controller;
transaction_pay_controller --> ramps_controller;
transaction_pay_controller --> remote_feature_flag_controller;
Expand Down
12 changes: 12 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `TransactionPayController:submitMoneyAccountVaultDeposit` action to vault a completed mUSD payout into the Money Account vault, resolving the deposit amount from the payout transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849))
- Add `TransactionPayController:submitMoneyAccountVaultWithdraw` action to redeem vmUSD and transfer the resulting mUSD to a given recipient in a single atomic, user-confirmed batch ([#9849](https://github.com/MetaMask/core/pull/9849))

### Changed

- Slim `SubmitMoneyAccountVaultWithdrawRequest` to on-chain fields only (`amountInRaw`, `moneyAccountAddress`, `recipient`, `requestId`); quote / chain / token validation stays outside Core ([#9849](https://github.com/MetaMask/core/pull/9849))
- Return `{ skipped: true }` from Money Account vault deposit helpers when vaulting is disabled instead of a fake `0x` transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849))
- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823))

### Fixed

- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval ([#9849](https://github.com/MetaMask/core/pull/9849))
- Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849))

## [26.3.0]

### Added
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@metamask/keyring-controller": "^27.1.1",
"@metamask/messenger": "^2.0.0",
"@metamask/metamask-eth-abis": "^3.1.1",
"@metamask/money-account-utils": "^1.1.0",
"@metamask/network-controller": "^35.0.1",
"@metamask/ramps-controller": "^20.0.0",
"@metamask/remote-feature-flag-controller": "^5.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ export type TransactionPayControllerUpdateFiatPaymentAction = {
handler: TransactionPayController['updateFiatPayment'];
};

/**
* Vaults mUSD received in a completed Iron payout transaction.
*
* Concurrent calls for the same payout hash share one in-flight submission.
* Successful results are retained so retries return the prior hash without
* submitting again.
*
* @param request - Completed Iron payout details.
* @returns Hash of the confirmed vault transaction, or `{ skipped: true }`
* when vaulting is disabled.
*/
export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = {
type: `TransactionPayController:submitMoneyAccountVaultDeposit`;
handler: TransactionPayController['submitMoneyAccountVaultDeposit'];
};

/**
* Creates a user-confirmed exact-out vmUSD withdrawal to Iron.
*
* Concurrent calls with the same request ID share one in-flight batch setup.
* Successful batch results are retained so a later call returns the same
* `batchId` without creating another approval.
*
* @param request - Backend-bound exact-out Iron intent.
* @returns Pending transaction batch ID.
*/
export type TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction = {
type: `TransactionPayController:submitMoneyAccountVaultWithdraw`;
handler: TransactionPayController['submitMoneyAccountVaultWithdraw'];
};

/**
* Gets the delegation transaction for a given transaction.
*
Expand Down Expand Up @@ -144,6 +175,8 @@ export type TransactionPayControllerMethodActions =
| TransactionPayControllerSetTransactionConfigAction
| TransactionPayControllerUpdatePaymentTokenAction
| TransactionPayControllerUpdateFiatPaymentAction
| TransactionPayControllerSubmitMoneyAccountVaultDepositAction
| TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction
| TransactionPayControllerGetDelegationTransactionAction
| TransactionPayControllerGetAmountDataAction
| TransactionPayControllerGetFiatOptionsAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
UpdateTransactionDataCallback,
} from './types.js';
import { getStrategyOrder } from './utils/feature-flags.js';
import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js';
import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js';
import { updateQuotes } from './utils/quotes.js';
import { updateSourceAmounts } from './utils/source-amounts.js';
import {
Expand All @@ -31,6 +33,8 @@
jest.mock('./utils/quotes');
jest.mock('./utils/transaction');
jest.mock('./utils/feature-flags');
jest.mock('./utils/ma-vault-payout');
jest.mock('./utils/ma-vault-withdraw');

const TRANSACTION_ID_MOCK = '123-456';
const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta;
Expand All @@ -50,6 +54,12 @@
);
const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges);
const getStrategyOrderMock = jest.mocked(getStrategyOrder);
const submitMoneyAccountVaultDepositFromPayoutMock = jest.mocked(
submitMoneyAccountVaultDepositFromPayout,
);
const submitMoneyAccountVaultWithdrawUtilMock = jest.mocked(
submitMoneyAccountVaultWithdrawUtil,
);
let messenger: TransactionPayControllerMessenger;
let getKeyringControllerStateMock: jest.Mock;

Expand Down Expand Up @@ -106,6 +116,205 @@
});
});

describe('Money Account vault actions', () => {
const moneyAccountAddress =
'0x1111111111111111111111111111111111111111' as Hex;
const transactionHash =
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex;
const recipient = '0x2222222222222222222222222222222222222222' as Hex;

it('exposes the payout deposit action through the messenger', async () => {
submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({
transactionHash,
});
createController();

const result = await messenger.call(
'TransactionPayController:submitMoneyAccountVaultDeposit',
{
moneyAccountAddress,
transactionHash,
},
);

expect(submitMoneyAccountVaultDepositFromPayoutMock).toHaveBeenCalledWith(
{ moneyAccountAddress, transactionHash },
messenger,
);
expect(result).toStrictEqual({ transactionHash });
});

it('deduplicates concurrent payout deposit actions by transaction hash', async () => {
let resolveSubmit:
| ((value: { transactionHash?: Hex }) => void)
| undefined;
submitMoneyAccountVaultDepositFromPayoutMock.mockImplementation(
async () =>
await new Promise((resolve) => {
resolveSubmit = resolve;
}),
);
const controller = createController();
const request = { moneyAccountAddress, transactionHash };

const first = controller.submitMoneyAccountVaultDeposit(request);
const second = controller.submitMoneyAccountVaultDeposit(request);
resolveSubmit?.({ transactionHash });

expect(await first).toStrictEqual({ transactionHash });
expect(await second).toStrictEqual({ transactionHash });
expect(
submitMoneyAccountVaultDepositFromPayoutMock,
).toHaveBeenCalledTimes(1);
});

it('returns the prior result on retry after a successful deposit without resubmitting', async () => {
submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({
transactionHash,
});
const controller = createController();
const request = { moneyAccountAddress, transactionHash };

const first = await controller.submitMoneyAccountVaultDeposit(request);
const second = await controller.submitMoneyAccountVaultDeposit(request);

expect(first).toStrictEqual({ transactionHash });
expect(second).toStrictEqual({ transactionHash });
expect(
submitMoneyAccountVaultDepositFromPayoutMock,
).toHaveBeenCalledTimes(1);
});

it('retries after a failed deposit', async () => {
submitMoneyAccountVaultDepositFromPayoutMock
.mockRejectedValueOnce(new Error('vault failed'))
.mockResolvedValueOnce({ transactionHash });
const controller = createController();
const request = { moneyAccountAddress, transactionHash };

await expect(
controller.submitMoneyAccountVaultDeposit(request),
).rejects.toThrow('vault failed');

await expect(
controller.submitMoneyAccountVaultDeposit(request),
).resolves.toStrictEqual({ transactionHash });

Check failure on line 201 in packages/transaction-pay-controller/src/TransactionPayController.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Use `expect(await promise)` instead
expect(
submitMoneyAccountVaultDepositFromPayoutMock,
).toHaveBeenCalledTimes(2);
});

it('retries after a skipped deposit once vaulting is enabled', async () => {
submitMoneyAccountVaultDepositFromPayoutMock
.mockResolvedValueOnce({ skipped: true })
.mockResolvedValueOnce({ transactionHash });
const controller = createController();
const request = { moneyAccountAddress, transactionHash };

await expect(
controller.submitMoneyAccountVaultDeposit(request),
).resolves.toStrictEqual({ skipped: true });

Check failure on line 216 in packages/transaction-pay-controller/src/TransactionPayController.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Use `expect(await promise)` instead

await expect(
controller.submitMoneyAccountVaultDeposit(request),
).resolves.toStrictEqual({ transactionHash });

Check failure on line 220 in packages/transaction-pay-controller/src/TransactionPayController.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Use `expect(await promise)` instead
expect(
submitMoneyAccountVaultDepositFromPayoutMock,
).toHaveBeenCalledTimes(2);
});

it('exposes the exact-out withdraw action through the messenger', async () => {
submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({
batchId: '0x123' as Hex,
});
createController();
const request = {
amountInRaw: '5000000',
moneyAccountAddress,
recipient,
requestId: 'request-id',
};

const result = await messenger.call(
'TransactionPayController:submitMoneyAccountVaultWithdraw',
request,
);

expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledWith(
request,
messenger,
);
expect(result).toStrictEqual({ batchId: '0x123' });
});

it('deduplicates concurrent withdraw actions by request ID', async () => {
let resolveSubmit: ((value: { batchId: Hex }) => void) | undefined;
submitMoneyAccountVaultWithdrawUtilMock.mockImplementation(
async () =>
await new Promise((resolve) => {
resolveSubmit = resolve;
}),
);
const controller = createController();
const request = {
amountInRaw: '5000000',
moneyAccountAddress,
recipient,
requestId: 'request-id',
};

const first = controller.submitMoneyAccountVaultWithdraw(request);
const second = controller.submitMoneyAccountVaultWithdraw(request);
resolveSubmit?.({ batchId: '0x123' });

expect(await first).toStrictEqual({ batchId: '0x123' });
expect(await second).toStrictEqual({ batchId: '0x123' });
expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1);
});

it('returns the same batchId on retry after approval is created without resubmitting', async () => {
submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({
batchId: '0x123' as Hex,
});
const controller = createController();
const request = {
amountInRaw: '5000000',
moneyAccountAddress,
recipient,
requestId: 'request-id',
};

const first = await controller.submitMoneyAccountVaultWithdraw(request);
const second = await controller.submitMoneyAccountVaultWithdraw(request);

expect(first).toStrictEqual({ batchId: '0x123' });
expect(second).toStrictEqual({ batchId: '0x123' });
expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1);
});

it('retries withdraw after a failed batch setup', async () => {
submitMoneyAccountVaultWithdrawUtilMock
.mockRejectedValueOnce(new Error('batch failed'))
.mockResolvedValueOnce({ batchId: '0x123' as Hex });
const controller = createController();
const request = {
amountInRaw: '5000000',
moneyAccountAddress,
recipient,
requestId: 'request-id',
};

await expect(
controller.submitMoneyAccountVaultWithdraw(request),
).rejects.toThrow('batch failed');

await expect(
controller.submitMoneyAccountVaultWithdraw(request),
).resolves.toStrictEqual({ batchId: '0x123' });

Check failure on line 313 in packages/transaction-pay-controller/src/TransactionPayController.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Use `expect(await promise)` instead
expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(2);
});
});

describe('updatePaymentToken', () => {
it('calls util', () => {
createController().updatePaymentToken({
Expand Down
Loading
Loading