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: 6 additions & 5 deletions .github/workflows/build-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
required: true
type: string
environment:
description: 'Build environment / track. Must be one of: exp, beta, rc.'
description: 'Build environment / track. Must be one of: exp, beta, rc, prod.'
required: true
type: string
upload_to_sentry:
Expand Down Expand Up @@ -43,9 +43,10 @@ on:
required: true
type: choice
options:
- exp
- beta
- prod
- rc
- beta
- exp
default: rc
upload_to_sentry:
description: 'Upload JS source maps and native debug symbols to Sentry during the build (requires Sentry auth in the build environment)'
Expand Down Expand Up @@ -75,8 +76,8 @@ jobs:
ENVIRONMENT: ${{ inputs.environment }}
run: |
case "$ENVIRONMENT" in
exp|beta|rc) echo "✅ environment=$ENVIRONMENT is allowed" ;;
*) echo "::error::Invalid environment '$ENVIRONMENT'. Must be one of: exp, beta, rc"; exit 1 ;;
exp|beta|rc|prod) echo "✅ environment=$ENVIRONMENT is allowed" ;;
*) echo "::error::Invalid environment '$ENVIRONMENT'. Must be one of: exp, beta, rc, prod"; exit 1 ;;
esac

generate-build-version:
Expand Down
11 changes: 8 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,18 @@ release-test-plan.json
release-delta.json
release-signoffs.json

# Per-engineer skills config (auto-generated by Consensys/skills sync).
# Per-engineer skills config (auto-generated by MetaMask/skills sync).
# Copy `.skills.local.example` to `.skills.local` and edit `SKILLS_DOMAINS=`.
.skills.local

# Local cache used by `postinstall` to auto-update skills from the public
# MetaMask/skills repo. Safe to delete — postinstall recreates on next install.
.skills-cache/

# Agent skills/commands/rules — never tracked. Synced via `yarn skills` from
# Consensys/skills, or hand-authored locally. (See ADR #57.) Only IDE/bugbot
# config under `.cursor/` is tracked via the carve-outs below.
# MetaMask/skills (public) and optionally Consensys/skills (private overlay),
# or hand-authored locally. (See ADR #57.) Only IDE/bugbot config under
# `.cursor/` is tracked via the carve-outs below.
.claude/skills/
.claude/commands/
.agents/skills/
Expand Down
24 changes: 19 additions & 5 deletions .skills.local.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
# Template for per-engineer skills config used by `yarn skills`.
# Copy this file to `.skills.local` (gitignored) and set the domains
# you want installed by default.
# Copy this file to `.skills.local` (gitignored).
#
# Examples:
# SKILLS_DOMAINS= # interactive prompt each run
# Zero-config default: the `postinstall` hook clones MetaMask/skills into
# `.skills-cache/metamask-skills` on every `yarn install`. `yarn skills`
# auto-detects that cache when no env var is set — nothing to do.
#
# Optional persistent skills config belongs in this file. Environment variables
# with the same names are only for one-off shell or CI overrides and take
# precedence over this file.
# METAMASK_SKILLS_DIR path to MetaMask/skills checkout (public, no auth)
# CONSENSYS_SKILLS_DIR path to Consensys/skills checkout (private overlay)
#
# Example local setup (only if you want to override the cache):
# METAMASK_SKILLS_DIR=~/dev/metamask/skills
# CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # optional
#
# Default behavior installs ALL domains. Set SKILLS_DOMAINS to opt out of some:
# SKILLS_DOMAINS= # all (default)
# SKILLS_DOMAINS=perps # single domain
# SKILLS_DOMAINS=perps,testing,pr # multiple domains
# SKILLS_DOMAINS=perps,testing,pr-workflow # multiple domains
#
# Override per-run with `SKILLS_DOMAINS=... yarn skills` or `--domain <list>`.
# Pick interactively with `yarn skills --select`.
# Use `yarn skills --reset` to wipe.
SKILLS_DOMAINS=
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,25 +199,27 @@ yarn start:android

### AI Agent Skills (`yarn skills`)

AI coding agents (Cursor, Claude Code, Codex) consume shared skills from the [Consensys/skills](https://github.com/Consensys/skills) repo. Per [ADR #57](https://github.com/MetaMask/decisions/pull/162) this content is **not committed here** — `yarn skills` syncs it on demand into local-only paths under `.cursor/`, `.claude/`, and `.agents/`.
AI coding agents (Cursor, Claude Code, Codex) consume shared skills from the [MetaMask/skills](https://github.com/MetaMask/skills) repo, with an optional private overlay from [Consensys/skills](https://github.com/Consensys/skills). Per [ADR #57](https://github.com/MetaMask/decisions/pull/162) this content is **not committed here** — `yarn skills` syncs it on demand into local-only paths under `.cursor/`, `.claude/`, and `.agents/`.

One-time setup:
Zero-config setup:

```bash
git clone git@github.com:Consensys/skills.git ~/path/to/consensys-skills
export CONSENSYS_SKILLS_DIR=~/path/to/consensys-skills # add to your shell rc
yarn install # clones MetaMask/skills into .skills-cache/metamask-skills
yarn skills # syncs all default skills from the cache
```

Keep that checkout on `main` — `yarn skills` syncs from whatever revision is checked out there.

Then in this repo:
Optional local configuration:

```bash
yarn skills # interactive prompt
SKILLS_DOMAINS=perps,testing yarn skills # non-interactive
cp .skills.local.example .skills.local
# edit .skills.local to set SKILLS_DOMAINS or override skills source paths
yarn skills --select # interactively pick domains
SKILLS_DOMAINS=perps,testing yarn skills # one-off domain override
```

If `CONSENSYS_SKILLS_DIR` is unset, `yarn skills` prints the same setup instructions and exits. Skipping it is fine — it only affects agent tooling, not the app build.
Use `.skills.local` for persistent skills configuration. Shell environment variables with the same names are supported for one-off or CI overrides and take precedence.

Skipping `yarn skills` is fine — it only affects agent tooling, not the app build.

### Git Hooks (Husky)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const BridgeViewSelectorsIDs = {
FEE_DISCLAIMER: 'bridge-fee-disclaimer',
QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton',
MISSING_PRICE_BANNER: 'bridge-missing-price-banner',
APPROVAL_TOOLTIP: 'bridge-approval-text',
} as const;

export type BridgeViewSelectorsIDsType = typeof BridgeViewSelectorsIDs;
109 changes: 108 additions & 1 deletion app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
RequestStatus,
type QuoteResponse,
MetaMetricsSwapsEventSource,
BRIDGE_MM_FEE_RATE,
} from '@metamask/bridge-controller';
import { Hex } from '@metamask/utils';
import { isHardwareAccount } from '../../../../../util/address';
Expand Down Expand Up @@ -74,6 +75,16 @@ jest.mock('../../components/ApprovalText', () => {
};
});

jest.mock('../../../Rewards/components/RewardsVipBadge/RewardsVipBadge', () => {
const MockReact = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
return {
__esModule: true,
default: () =>
MockReact.createElement(View, { testID: 'rewards-vip-badge' }),
};
});

// ─── Helpers ─────────────────────────────────────────────────────────────────

const mockLocation = MetaMetricsSwapsEventSource.MainView;
Expand Down Expand Up @@ -303,6 +314,29 @@ describe('BridgeViewFooter', () => {
});
});

it('shows discounted fee disclaimer with fee percentage when fee is less than base fee', async () => {
jest
.mocked(useBridgeQuoteData as unknown as jest.Mock)
.mockImplementation(() => ({
...mockUseBridgeQuoteData,
activeQuote: {
...mockQuoteWithMetadata,
quote: {
feeData: { metabridge: { quoteBpsFee: 57.5, baseBpsFee: 90 } },
},
},
}));

const { getByTestId } = renderFooter(buildActiveQuoteState());

await waitFor(() => {
expect(getByTestId('rewards-vip-badge')).toBeTruthy();
expect(
getByTestId(BridgeViewSelectorsIDs.FEE_DISCLAIMER),
).toHaveTextContent('Includes0.9%0.575% MM fee.');
});
});

it('shows no MM fee disclaimer when dest token is mUSD and fee is zero', async () => {
const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex;

Expand All @@ -313,7 +347,14 @@ describe('BridgeViewFooter', () => {
isLoading: false,
activeQuote: {
...(mockQuoteWithMetadata as unknown as QuoteResponse),
quote: { feeData: { metabridge: { quoteBpsFee: 0 } } },
quote: {
...mockQuoteWithMetadata.quote,
destAsset: {
...mockQuoteWithMetadata.quote.destAsset,
symbol: 'mUSD',
},
feeData: { metabridge: { quoteBpsFee: 0, baseBpsFee: 87.5 } },
},
} as unknown as QuoteResponse,
}));

Expand Down Expand Up @@ -354,6 +395,72 @@ describe('BridgeViewFooter', () => {
).toBeTruthy();
});
});

it('shows fee disclaimer when fee is undefined', async () => {
const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex;

jest
.mocked(useBridgeQuoteData as unknown as jest.Mock)
.mockImplementation(() => ({
...mockUseBridgeQuoteData,
isLoading: false,
activeQuote: {
...(mockQuoteWithMetadata as unknown as QuoteResponse),
quote: {
...mockQuoteWithMetadata.quote,
destAsset: {
...mockQuoteWithMetadata.quote.destAsset,
symbol: 'mUSD',
},
feeData: {
metabridge: { quoteBpsFee: undefined, baseBpsFee: undefined },
},
},
} as unknown as QuoteResponse,
}));

const testState = createBridgeTestState({
bridgeControllerOverrides: {
quotesLoadingStatus: RequestStatus.FETCHED,
quotes: [mockQuoteWithMetadata as unknown as QuoteResponse],
quotesLastFetched: 12,
},
bridgeReducerOverrides: {
sourceAmount: '1.0',
sourceToken: {
address: '0x0000000000000000000000000000000000000000',
chainId: '0x1' as Hex,
decimals: 18,
image: '',
name: 'Ether',
symbol: 'ETH',
},
destToken: {
address: musdAddress,
chainId: '0x1' as Hex,
decimals: 6,
image: '',
name: 'MetaMask USD',
symbol: 'mUSD',
},
},
});

const { getByText } = renderFooter(testState as DeepPartial<RootState>);

await waitFor(() => {
expect(
getByText(
strings('bridge.fee_disclaimer', {
feePercentage: BRIDGE_MM_FEE_RATE,
}),
{
exact: false,
},
),
).toBeTruthy();
});
});
});

describe('Approval Disclaimer', () => {
Expand Down
Loading
Loading