Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .github/workflows/build-and-upload-to-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ on:
required: false
type: string
default: 'MetaMask BETA & Release Candidates'
distribute_external:
description: 'Whether to distribute to external testers. Defaults to false; nightly-build.yml relies on the script default (true) so it always distributes externally.'
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
source_branch:
Expand All @@ -44,6 +49,11 @@ on:
- 'MetaMask BETA & Release Candidates'
- 'MM Card Team'
- 'Ramp Provider Testing'
distribute_external:
description: 'Whether to distribute to external testers'
required: false
type: boolean
default: false

permissions:
contents: write
Expand Down Expand Up @@ -79,6 +89,7 @@ jobs:
build_commit_sha: ${{ needs.build.outputs.built_commit_sha }}
build_version: ${{ needs.build.outputs.semantic_version }}
build_number: ${{ needs.build.outputs.ios_version_code }}
distribute_external: ${{ inputs.distribute_external }}
secrets: inherit

cleanup-build-branch:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/nightly-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
source_branch: main
environment: exp
testflight_group: 'MetaMask BETA & Release Candidates'
distribute_external: true
secrets: inherit

# ── iOS rc: build + TestFlight upload (after exp for sequential versions) ─
Expand All @@ -42,6 +43,7 @@ jobs:
source_branch: main
environment: rc
testflight_group: 'MetaMask BETA & Release Candidates'
distribute_external: true
secrets: inherit

# ── Android exp: ephemeral branch + build ──────────────────────────────
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/upload-to-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ on:
description: 'The build number of the app (eg. 4134)'
required: true
type: string
distribute_external:
description: 'Whether to distribute to external testers. Set false for nightly/internal builds to avoid expiring previous TestFlight builds.'
required: false
type: boolean
default: true

permissions:
contents: read
Expand Down Expand Up @@ -157,7 +162,8 @@ jobs:
"github_actions_main-${{ inputs.environment }}" \
"${{ inputs.source_branch }}" \
"${{ steps.ipa.outputs.path }}" \
"${{ inputs.testflight_group }}"
"${{ inputs.testflight_group }}" \
"${{ inputs.distribute_external }}"

- name: Cleanup API Key
if: always()
Expand Down
42 changes: 10 additions & 32 deletions app/actions/multiSrp/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { KeyringTypes } from '@metamask/keyring-controller';
import ExtendedKeyringTypes from '../../constants/keyringTypes';
import Engine from '../../core/Engine';
import {
importNewSecretRecoveryPhrase,
createNewSecretRecoveryPhrase,
addNewHdAccount,
} from './';
import { importNewSecretRecoveryPhrase, addNewHdAccount } from './';
import { createMockInternalAccount } from '../../util/test/accountsControllerTestUtils';
import { TraceName, TraceOperation } from '../../util/trace';
import ReduxService from '../../core/redux/ReduxService';
Expand Down Expand Up @@ -57,6 +53,13 @@ const hdKeyring = {
},
};

const hdKeyringV2 = {
getAccounts: () => {
mockGetAccounts();
return [{ address: mockAddress }];
},
};

jest.mock('../../selectors/seedlessOnboardingController', () => ({
selectSeedlessOnboardingLoginFlow: (state: unknown) =>
mockSelectSeedlessOnboardingLoginFlow(state),
Expand Down Expand Up @@ -103,6 +106,8 @@ jest.mock('../../core/Engine', () => ({
getKeyringsByType: () => mockGetKeyringsByType(),
withKeyring: (_selector: unknown, operation: (args: unknown) => void) =>
operation({ keyring: hdKeyring, metadata: { id: '1234' } }),
withKeyringV2: (_selector: unknown, operation: (args: unknown) => void) =>
operation({ keyring: hdKeyringV2, metadata: { id: '1234' } }),
},
AccountsController: {
getNextAvailableAccountName: jest.fn().mockReturnValue('Snap Account 1'),
Expand Down Expand Up @@ -474,33 +479,6 @@ describe('MultiSRP Actions', () => {
});
});

describe('createNewSecretRecoveryPhrase', () => {
it('creates new SRP', async () => {
mockAddNewKeyring.mockResolvedValue({
getAccounts: () => Promise.resolve([mockAddress]),
});

await createNewSecretRecoveryPhrase();

expect(mockAddNewKeyring).toHaveBeenCalledWith(
KeyringTypes.hd,
undefined,
);
expect(mockSetSelectedAddress).toHaveBeenCalledWith(mockAddress);
});

it('Does not set selected address or gets accounts on errors', async () => {
mockAddNewKeyring.mockRejectedValue(new Error('Test error'));

await expect(
async () => await createNewSecretRecoveryPhrase(),
).rejects.toThrow('Test error');

expect(mockGetAccounts).not.toHaveBeenCalled();
expect(mockSetSelectedAddress).not.toHaveBeenCalled();
});
});

describe('addNewHdAccount', () => {
it('adds a new HD account, sets the selected address and returns the account', async () => {
mockAddAccounts.mockReturnValue([mockAddress]);
Expand Down
26 changes: 5 additions & 21 deletions app/actions/multiSrp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export async function importNewSecretRecoveryPhrase(
});
const entropySource = wallet.entropySource;

const [newAccountAddress] = await KeyringController.withKeyring(
const [newAccount] = await KeyringController.withKeyringV2(
{
id: entropySource,
},
Expand Down Expand Up @@ -80,7 +80,7 @@ export async function importNewSecretRecoveryPhrase(
} catch (error) {
await MultichainAccountService.removeMultichainAccountWallet(
entropySource,
newAccountAddress,
newAccount.address,
);

const errorMessage =
Expand Down Expand Up @@ -129,34 +129,18 @@ export async function importNewSecretRecoveryPhrase(
} finally {
// We trigger the callback with the results, even in case of error (0 discovered accounts)
await callback?.({
address: newAccountAddress,
address: newAccount.address,
discoveredAccountsCount,
error: capturedError,
});
}
})();

if (shouldSelectAccount) {
Engine.setSelectedAddress(newAccountAddress);
Engine.setSelectedAddress(newAccount.address);
}

return { address: newAccountAddress, discoveredAccountsCount };
}

export async function createNewSecretRecoveryPhrase() {
const { KeyringController } = Engine.context;
const newHdkeyring = await KeyringController.addNewKeyring(
ExtendedKeyringTypes.hd,
);

const [newAccountAddress] = await KeyringController.withKeyring(
{
id: newHdkeyring.id,
},
async ({ keyring }) => keyring.getAccounts(),
);

return Engine.setSelectedAddress(newAccountAddress);
return { address: newAccount.address, discoveredAccountsCount };
}

export async function addNewHdAccount(
Expand Down
153 changes: 151 additions & 2 deletions app/components/UI/Predict/controllers/PredictController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ import {
import type { PredictFeatureFlags } from '../types/flags';

import { PREDICT_ERROR_CODES } from '../constants/errors';
import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants';
import {
MATIC_CONTRACTS_V2,
POLYMARKET_PROVIDER_ID,
} from '../providers/polymarket/constants';
// Mock the PolymarketProvider and its dependencies
jest.mock('../providers/polymarket/PolymarketProvider');

Expand Down Expand Up @@ -280,6 +283,8 @@ describe('PredictController', () => {
getCryptoTargetPrice: jest.fn(),
invalidateAccountState: jest.fn(),
beforePublishDepositWalletDeposit: jest.fn(),
beforeSignClaim: jest.fn(),
publishClaim: jest.fn(),
syncDepositWalletBalanceAllowanceForDepositTransaction: jest.fn(),
} as unknown as jest.Mocked<PolymarketProvider>;

Expand All @@ -289,6 +294,9 @@ describe('PredictController', () => {
mockPolymarketProvider.syncDepositWalletBalanceAllowanceForDepositTransaction.mockResolvedValue(
undefined,
);
mockPolymarketProvider.publishClaim?.mockResolvedValue({
transactionHash: undefined,
});

// Default safe mocks for async fire-and-forget methods
// (prevents unhandled rejections when payWithAnyTokenConfirmation is
Expand Down Expand Up @@ -2732,7 +2740,14 @@ describe('PredictController', () => {
address: '0x1234567890123456789012345678901234567890',
}),
});
expect(addTransactionBatch).toHaveBeenCalled();
expect(addTransactionBatch).toHaveBeenCalledWith(
expect.objectContaining({
disableHook: true,
disableSequential: true,
gasFeeToken: MATIC_CONTRACTS_V2.collateral,
skipInitialGasEstimate: true,
}),
);
});
});

Expand Down Expand Up @@ -6206,6 +6221,24 @@ describe('PredictController', () => {
});

describe('publish', () => {
const claimTransactionMeta = {
id: 'tx-claim',
batchId: 'batch-claim',
txParams: {
from: MOCK_ADDRESS,
to: '0xTarget',
data: '0xdata',
value: '0x0',
},
nestedTransactions: [
{
id: 'nested-claim',
type: TransactionType.predictClaim,
data: '0xclaim' as `0x${string}`,
},
],
} as unknown as TransactionMeta;

it('passes through by default', async () => {
await withController(async ({ controller }) => {
const result = await controller.publish({
Expand All @@ -6218,6 +6251,58 @@ describe('PredictController', () => {
});

expect(result).toEqual({ transactionHash: undefined });
expect(mockPolymarketProvider.publishClaim).not.toHaveBeenCalled();
});
});

it('delegates pending claims to provider.publishClaim', async () => {
mockPolymarketProvider.publishClaim?.mockResolvedValue({
transactionHash:
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
});

await withController(async ({ controller }) => {
const claimablePositions = [createMockPosition({ claimable: true })];
controller.updateStateForTesting((state) => {
state.pendingClaims[MOCK_ADDRESS.toUpperCase()] = 'batch-claim';
state.claimablePositions[MOCK_ADDRESS.toUpperCase()] =
claimablePositions;
});

const result = await controller.publish({
transactionMeta: claimTransactionMeta,
});

expect(result).toEqual({
transactionHash:
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
});
expect(mockPolymarketProvider.publishClaim).toHaveBeenCalledWith({
transactionMeta: claimTransactionMeta,
signer: expect.objectContaining({ address: MOCK_ADDRESS }),
positions: claimablePositions,
});
expect(
(mockPolymarketProvider.publishClaim as jest.Mock).mock.calls[0][0]
.positions,
).not.toBe(claimablePositions);
});
});

it('throws on pending claim batch mismatch', async () => {
await withController(async ({ controller }) => {
controller.updateStateForTesting((state) => {
state.pendingClaims[MOCK_ADDRESS] = 'different-batch';
state.claimablePositions[MOCK_ADDRESS] = [
createMockPosition({ claimable: true }),
];
});

await expect(
controller.publish({ transactionMeta: claimTransactionMeta }),
).rejects.toThrow(
'Pending claim batch does not match transaction batch',
);
});
});
});
Expand Down Expand Up @@ -6540,6 +6625,70 @@ describe('PredictController', () => {
expect(result).toBeUndefined();
});
});

it('delegates pending claim beforeSign even when no withdraw state exists', async () => {
const updateTransaction = jest.fn();
mockPolymarketProvider.beforeSignClaim?.mockResolvedValue({
updateTransaction,
});

await withController(async ({ controller }) => {
const claimablePositions = [createMockPosition({ claimable: true })];
controller.updateStateForTesting((state) => {
state.pendingClaims[MOCK_ADDRESS] = 'batch-claim';
state.claimablePositions[MOCK_ADDRESS] = claimablePositions;
});

const transactionMeta = {
...mockTransactionMeta,
batchId: 'batch-claim',
nestedTransactions: [
{
id: 'nested-claim',
type: TransactionType.predictClaim,
data: '0xclaim' as `0x${string}`,
},
],
} as unknown as TransactionMeta;

const result = await controller.beforeSign({ transactionMeta });

expect(result).toEqual({ updateTransaction });
expect(mockPolymarketProvider.beforeSignClaim).toHaveBeenCalledWith({
transactionMeta,
signer: expect.objectContaining({ address: MOCK_ADDRESS }),
positions: claimablePositions,
});
expect(
(mockPolymarketProvider.beforeSignClaim as jest.Mock).mock.calls[0][0]
.positions,
).not.toBe(claimablePositions);
});
});

it('throws when pending claim has no claimable positions', async () => {
await withController(async ({ controller }) => {
controller.updateStateForTesting((state) => {
state.pendingClaims[MOCK_ADDRESS] = 'batch-claim';
state.claimablePositions[MOCK_ADDRESS] = [];
});

await expect(
controller.beforeSign({
transactionMeta: {
...mockTransactionMeta,
batchId: 'batch-claim',
nestedTransactions: [
{
type: TransactionType.predictClaim,
data: '0xclaim' as `0x${string}`,
},
],
} as unknown as TransactionMeta,
}),
).rejects.toThrow('No claimable positions found for pending claim');
});
});
});

describe('clearWithdrawTransaction', () => {
Expand Down
Loading
Loading