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
65 changes: 65 additions & 0 deletions app/scripts/lib/account-supports-7702.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { accountSupports7702 } from './account-supports-7702';

const ADDRESS_MOCK = '0x1234567890123456789012345678901234567890';

function keyringControllerWithType(type: string) {
return {
getKeyringForAccount: jest.fn().mockResolvedValue({ type }),
};
}

describe('accountSupports7702', () => {
it('returns true for HD keyring accounts', async () => {
expect(
await accountSupports7702(
ADDRESS_MOCK,
keyringControllerWithType('HD Key Tree'),
),
).toBe(true);
});

it('returns true for simple keyring accounts', async () => {
expect(
await accountSupports7702(
ADDRESS_MOCK,
keyringControllerWithType('Simple Key Pair'),
),
).toBe(true);
});

it('returns true for money keyring accounts', async () => {
// Sponsored Money Account transactions (e.g. Monad withdrawals) are
// externally signed and must publish via the EIP-7702 relay; treating the
// money keyring as unsupported skipped the relay hook and raw-sent an
// unsigned payload.
expect(
await accountSupports7702(
ADDRESS_MOCK,
keyringControllerWithType('Money Keyring'),
),
).toBe(true);
});

it('returns false for hardware keyring accounts', async () => {
expect(
await accountSupports7702(
ADDRESS_MOCK,
keyringControllerWithType('Ledger Hardware'),
),
).toBe(false);
});

it('returns true when the address is missing', async () => {
expect(
await accountSupports7702(undefined, keyringControllerWithType('any')),
).toBe(true);
});

it('returns true when the keyring lookup fails', async () => {
expect(
await accountSupports7702(ADDRESS_MOCK, {
getKeyringForAccount: jest.fn().mockRejectedValue(new Error('nope')),
}),
).toBe(true);
});
});
4 changes: 2 additions & 2 deletions app/scripts/lib/account-supports-7702.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { KeyringControllerGetKeyringForAccountAction } from '@metamask/keyring-controller';
import { KEYRING_TYPES_SUPPORTING_7702 } from '../../../shared/constants/keyring';
import { KEYRING_TYPES_SUPPORTING_7702_RELAY } from '../../../shared/constants/keyring';
import { RootMessenger } from './messenger';

/** Minimal shape; KeyringController.getKeyringForAccount is typed as Promise<unknown>. */
Expand Down Expand Up @@ -49,7 +49,7 @@ export async function accountSupports7702(
typeof (keyring as { type: unknown }).type === 'string'
? (keyring as { type: string }).type
: '';
return KEYRING_TYPES_SUPPORTING_7702.includes(keyringType as never);
return KEYRING_TYPES_SUPPORTING_7702_RELAY.includes(keyringType as never);
} catch {
return true;
}
Expand Down
15 changes: 7 additions & 8 deletions app/scripts/lib/money/pay/account-override.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,13 @@ const MONEY_ACCOUNT_TRANSACTION_TYPES: readonly TransactionType[] = [
* funding account to quote against. `isQuoteRequired` is set for deposits so
* Pay always fetches a quote even when the source and target tokens match.
*
* Skips non-EVM selected accounts, matching mobile. Two mobile branches are
* deliberately not ported: the Card-link approve discriminator (the extension
* has no Card product), and the nested-transaction address replacement for
* withdrawals (the extension's placeholder batches carry no calldata, and the
* withdraw commit path re-resolves the recipient from the selected account on
* every amount commit). Mobile's eager balance refresh is also skipped: the
* override is the selected account, whose balances the extension already
* polls while the UI is open.
* Skips non-EVM selected accounts, matching mobile. The Card-link approve
* discriminator is not ported (the extension has no Card product). Nested
* address replacement on add is skipped because the extension placeholder
* has no calldata — `FromAccountRow` rewrites encoded nested data when the
* user later changes account, matching mobile `PayAccountSelector`. Mobile's
* eager balance refresh is also skipped: the override is the selected
* account, whose balances the extension already polls while the UI is open.
*
* @param controller - The Pay controller to seed.
* @param messenger - The messenger to resolve the selected account through.
Expand Down
85 changes: 82 additions & 3 deletions app/scripts/lib/money/pay/update-withdraw-amount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,12 @@ describe('updateMoneyAccountWithdrawAmount', () => {
AMOUNT_HUMAN,
);

expect(result).toBe(true);
const meta = committed.meta as TransactionMeta;
expect(result).toEqual({
withdrawData: meta.nestedTransactions?.[0].data,
transferData: meta.nestedTransactions?.[1].data,
transactionData: meta.txParams.data,
});

const withdraw = TELLER_INTERFACE.decodeFunctionData(
'withdraw',
Expand All @@ -102,7 +106,6 @@ describe('updateMoneyAccountWithdrawAmount', () => {
expect(withdraw.withdrawAsset.toLowerCase()).toBe(
MUSD_ADDRESS.toLowerCase(),
);
// Shares are ceil(amount * ONE_SHARE / rate) at the mocked vault rate.
expect(withdraw.shareAmount.toBigInt()).toBe(
(AMOUNT_ROUNDED_UP * 1_000_000n + VAULT_RATE_MOCK - 1n) / VAULT_RATE_MOCK,
);
Expand All @@ -115,6 +118,7 @@ describe('updateMoneyAccountWithdrawAmount', () => {
expect(transfer.recipient.toLowerCase()).toBe(RECIPIENT_MOCK);
expect(transfer.amount.toBigInt()).toBe(AMOUNT_ROUNDED_UP);

expect(meta.type).toBe(TransactionType.moneyAccountWithdraw);
expect(meta.txParams.gas).toBeUndefined();
expect(meta.simulationData).toBeUndefined();
});
Expand Down Expand Up @@ -148,6 +152,22 @@ describe('updateMoneyAccountWithdrawAmount', () => {
);
});

it('encodes when nested slots exist without money-account types', async () => {
const transaction = buildTemplateTransaction();
delete transaction.nestedTransactions?.[0].type;
delete transaction.nestedTransactions?.[1].type;
const { messenger, committed } = setup(transaction);

const result = await updateMoneyAccountWithdrawAmount(
messenger,
transaction.id,
AMOUNT_HUMAN,
);

expect(result).not.toBe(false);
expect(committed.meta?.nestedTransactions?.[1].data).toBeDefined();
});

it('rejects when no recipient account resolves', async () => {
const transaction = buildTemplateTransaction();
const { messenger } = setup(transaction, {
Expand Down Expand Up @@ -179,6 +199,65 @@ describe('updateMoneyAccountWithdrawAmount', () => {
);

expect(second).toBe(first);
await expect(first).resolves.toBe(true);
await expect(first).resolves.toEqual(
expect.objectContaining({
transferData: expect.any(String),
withdrawData: expect.any(String),
}),
);
});

it('forwards mUSD to the recipient override instead of the selected account', async () => {
const transaction = buildTemplateTransaction();
const { messenger, committed } = setup(transaction);
const recipientOverride =
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex;

await updateMoneyAccountWithdrawAmount(
messenger,
transaction.id,
AMOUNT_HUMAN,
recipientOverride,
);

const meta = committed.meta as TransactionMeta;
const transfer = ERC20_INTERFACE.decodeFunctionData(
'transfer',
meta.nestedTransactions?.[1].data as string,
);
expect(transfer.recipient.toLowerCase()).toBe(
recipientOverride.toLowerCase(),
);
});

it('does not share the promise when the recipient override differs', async () => {
const transaction = buildTemplateTransaction();
const { messenger } = setup(transaction);
const firstRecipient =
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex;
const secondRecipient =
'0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex;

const first = updateMoneyAccountWithdrawAmount(
messenger,
transaction.id,
AMOUNT_HUMAN,
firstRecipient,
);
const second = updateMoneyAccountWithdrawAmount(
messenger,
transaction.id,
AMOUNT_HUMAN,
secondRecipient,
);

expect(second).not.toBe(first);
await expect(first).resolves.toBe(false);
await expect(second).resolves.toEqual(
expect.objectContaining({
transferData: expect.any(String),
withdrawData: expect.any(String),
}),
);
});
});
Loading
Loading