diff --git a/.github/workflows/check-analytics-contract.yml b/.github/workflows/check-analytics-contract.yml new file mode 100644 index 000000000000..f8374c944088 --- /dev/null +++ b/.github/workflows/check-analytics-contract.yml @@ -0,0 +1,143 @@ +name: Check deployed analytics contract + +# periodically/manual -> download newest deployed analytics contract -> verify it -> +# generate pilot -> type-check -> notify success/failure -> change nothing. +# +# more details in /docs/readme/typed-analytics-contracts.md + +on: + schedule: + # Hourly from 06:00 through 22:00 UTC on weekdays. + - cron: '0 6-22 * * 1-5' + workflow_dispatch: + inputs: + release-tag: + description: Optional exact analytics contract release tag + required: false + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: check-deployed-analytics-contract + cancel-in-progress: true + +jobs: + verify-mobile-contract: + name: Verify latest deployed Mobile contract + environment: default-branch + runs-on: ubuntu-latest + timeout-minutes: 40 + outputs: + release-tag: ${{ steps.shared-contract.outputs.release-tag }} + release-url: ${{ steps.shared-contract.outputs.release-url }} + steps: + - name: Checkout Mobile + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: yarn + + - name: Install dependencies + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --immutable + + - name: Download contract with shared action + id: shared-contract + uses: MetaMask/github-tools/.github/actions/download-analytics-contract@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + target-repository: Consensys/segment-schema + asset-name: metamask-mobile.json + release-tag: ${{ inputs.release-tag }} + output-directory: ${{ runner.temp }}/analytics-contract + + - name: Check downloaded analytics contract + id: check + env: + SHARED_RELEASE_TAG: ${{ steps.shared-contract.outputs.release-tag }} + SHARED_RELEASE_URL: ${{ steps.shared-contract.outputs.release-url }} + SHARED_MANIFEST_PATH: ${{ steps.shared-contract.outputs.manifest-path }} + SHARED_ASSET_PATH: ${{ steps.shared-contract.outputs.asset-path }} + SHARED_ASSET_DIGEST: ${{ steps.shared-contract.outputs.asset-digest }} + JSON_OUTPUT: ${{ runner.temp }}/analytics-contract-check.json + GENERATED_OUTPUT_DIRECTORY: ${{ runner.temp }}/analytics-contract-check + shell: bash + run: | + set -euo pipefail + yarn analytics:contract:check \ + --manifest "${SHARED_MANIFEST_PATH}" \ + --contract "${SHARED_ASSET_PATH}" \ + --asset-sha256 "${SHARED_ASSET_DIGEST}" \ + --release-tag "${SHARED_RELEASE_TAG}" \ + --release-url "${SHARED_RELEASE_URL}" \ + --output-directory "${GENERATED_OUTPUT_DIRECTORY}" \ + --json-output "${JSON_OUTPUT}" + diff -u \ + app/util/analytics/generated/QuickBuyAmountSelected.ts \ + "${GENERATED_OUTPUT_DIRECTORY}/generated/QuickBuyAmountSelected.ts" + diff -u \ + app/util/analytics/generated/QuickBuyAmountSelected.test-d.ts \ + "${GENERATED_OUTPUT_DIRECTORY}/generated/QuickBuyAmountSelected.test-d.ts" + echo "release-tag=$(jq -r '.release.tag' "${JSON_OUTPUT}")" >> "${GITHUB_OUTPUT}" + echo "release-url=$(jq -r '.release.url' "${JSON_OUTPUT}")" >> "${GITHUB_OUTPUT}" + + - name: Summarize verification + if: always() + env: + RELEASE_TAG: ${{ steps.shared-contract.outputs.release-tag }} + RELEASE_URL: ${{ steps.shared-contract.outputs.release-url }} + CHECK_RESULT: ${{ steps.check.outcome }} + CHECK_FILE: ${{ runner.temp }}/analytics-contract-check.json + shell: bash + run: | + { + echo "### Deployed Mobile analytics contract" + echo + if [ -n "${RELEASE_TAG}" ]; then + echo "- Release: [\`${RELEASE_TAG}\`](${RELEASE_URL})" + else + echo "- Release: unresolved" + fi + echo "- Contract, generation, and type-check result: \`${CHECK_RESULT}\`" + + if [ -f "${CHECK_FILE}" ]; then + echo "- Source SHA: \`$(jq -r '.verification.sourceSha' "${CHECK_FILE}")\`" + echo "- Rule count: \`$(jq -r '.verification.ruleCount' "${CHECK_FILE}")\`" + echo "- Contract hash: \`$(jq -r '.verification.contractHash' "${CHECK_FILE}")\`" + echo "- Checked event versions: \`$(jq -r '.generated.versions | join(", ")' "${CHECK_FILE}")\`" + fi + } >> "${GITHUB_STEP_SUMMARY}" + + notify-slack: + name: Notify Slack + needs: verify-mobile-contract + if: >- + ${{ + always() && + !cancelled() + }} + environment: default-branch + runs-on: ubuntu-latest + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_PLAYWRIGHT_HEALTH_REPORT }} + steps: + - name: Send Slack notification + if: env.SLACK_WEBHOOK != '' + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a + with: + webhook: ${{ env.SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload: | + { + "text": "Mobile deployed analytics contract check: ${{ needs.verify-mobile-contract.result }}\nRelease: ${{ needs.verify-mobile-contract.outputs.release-tag || 'unresolved' }}\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } diff --git a/app/util/analytics/generated/QuickBuyAmountSelected.test-d.ts b/app/util/analytics/generated/QuickBuyAmountSelected.test-d.ts new file mode 100644 index 000000000000..f2577bba1cdf --- /dev/null +++ b/app/util/analytics/generated/QuickBuyAmountSelected.test-d.ts @@ -0,0 +1,41 @@ +import { createQuickBuyAmountSelected } from './QuickBuyAmountSelected'; + +declare const transport: Parameters[0]; + +const event = createQuickBuyAmountSelected(transport); + +event.v1.track({ + amount_selection_method: 'preset', + amount_usd: 1, + source: 'notification', +}); + +event.v2.track({ + amount_selection_method: 'preset', + amount_usd: 1, + source: 'notification', +}); + +// @ts-expect-error source is required +event.v2.track({ + amount_selection_method: 'custom_input', + amount_usd: 1, +}); + +event.v2.track({ + amount_selection_method: 'preset', + amount_usd: 1, + source: 'notification', + // @ts-expect-error unknown properties are rejected + unknown_property: true, +}); + +event.v1.track({ + // @ts-expect-error slider is not a v1 amount selection method + amount_selection_method: 'slider', + amount_usd: 1, + source: 'notification', +}); + +// @ts-expect-error only generated versions are available +event.v3.track({}); diff --git a/app/util/analytics/generated/QuickBuyAmountSelected.test.ts b/app/util/analytics/generated/QuickBuyAmountSelected.test.ts new file mode 100644 index 000000000000..b14b7d038230 --- /dev/null +++ b/app/util/analytics/generated/QuickBuyAmountSelected.test.ts @@ -0,0 +1,52 @@ +import { createQuickBuyAmountSelected } from './QuickBuyAmountSelected'; + +describe('createQuickBuyAmountSelected', () => { + it('tracks v1 with the event version context', () => { + const transport = { + track: jest.fn(), + }; + const event = createQuickBuyAmountSelected(transport); + const properties = { + amount_selection_method: 'preset' as const, + amount_usd: 50, + source: 'notification' as const, + }; + + event.v1.track(properties); + + expect(transport.track).toHaveBeenCalledWith( + 'Quick Buy Amount Selected', + properties, + { + protocols: { + event_version: 1, + }, + }, + ); + }); + + it('tracks v2 with the event version context', () => { + const transport = { + track: jest.fn(), + }; + const event = createQuickBuyAmountSelected(transport); + const properties = { + amount_selection_method: 'slider' as const, + amount_usd: 50, + slider_percent: 25, + source: 'trader_feed' as const, + }; + + event.v2.track(properties); + + expect(transport.track).toHaveBeenCalledWith( + 'Quick Buy Amount Selected', + properties, + { + protocols: { + event_version: 2, + }, + }, + ); + }); +}); diff --git a/app/util/analytics/generated/QuickBuyAmountSelected.ts b/app/util/analytics/generated/QuickBuyAmountSelected.ts new file mode 100644 index 000000000000..cda6c3c93f07 --- /dev/null +++ b/app/util/analytics/generated/QuickBuyAmountSelected.ts @@ -0,0 +1,131 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Generated from the verified deployed Mobile analytics contract. + */ + +export interface AnalyticsEventContext { + readonly protocols: { + readonly event_version: number; + }; +} + +export interface AnalyticsEventTransport { + track( + eventName: string, + properties: object, + context: AnalyticsEventContext, + ): void; +} + +type ExactProperties = Actual & + Record, never>; + +interface EventVersion { + track( + properties: ExactProperties, + ): void; +} + +const createEventVersion = ( + transport: AnalyticsEventTransport, + eventName: string, + version: number, +): EventVersion => ({ + track: (properties) => { + transport.track(eventName, properties as object, { + protocols: { + event_version: version, + }, + }); + }, +}); + +const EVENT_NAME = 'Quick Buy Amount Selected'; + +export interface QuickBuyAmountSelectedV1Properties { + /** How the user picked the amount. */ + readonly amount_selection_method: 'preset' | 'custom_input'; + /** Selected USD amount. */ + readonly amount_usd: number; + /** CAIP-19 identifier of the token associated with the event. */ + readonly caip19?: string; + /** Symbol of the source token currently selected to pay with (may be undefined before token resolution finishes). */ + readonly pay_with_token?: string; + /** Preset chip value, when amount_selection_method is preset. */ + readonly preset_value?: 20 | 50 | 100 | 250; + /** Surface from which the QuickBuy sheet was opened. */ + readonly source: + | 'notification' + | 'profile_position' + | 'leaderboard' + | 'asset_details' + | 'market_insights' + | 'security_trust' + | 'explore_search' + | 'explore_crypto' + | 'explore_now' + | 'explore_rwas' + | 'explore_trending' + | 'explore_stocks'; + /** Wallet address of the trader. May be absent on cold-start notification deeplinks where only a profile UUID is available. */ + readonly trader_address?: string; +} + +export interface QuickBuyAmountSelectedV2Properties { + /** How the user picked the amount. */ + readonly amount_selection_method: 'preset' | 'custom_input' | 'slider'; + /** Selected USD amount. */ + readonly amount_usd: number; + /** CAIP-19 identifier of the spot token associated with the event, including chain ID. Sent for spot positions only; absent for perps. */ + readonly caip19?: string; + /** Lowercase chain slug for the position. Spot examples: base, ethereum, solana. Perps: hyperliquid. Present on follow-trading token events when a position context is resolved. */ + readonly chain_name?: string; + /** How the user reached the follow-trading token (trade) screen before opening Quick Buy. Only set when Quick Buy is opened from the trade screen. */ + readonly original_entry_point?: + | 'leaderboard' + | 'trader_profile' + | 'notification' + | 'deep_link' + | 'home_carousel'; + /** Symbol of the source token currently selected to pay with (may be undefined before token resolution finishes). */ + readonly pay_with_token?: string; + /** Tradable Hyperliquid perps market symbol. Sent for perp positions only; absent for spot. */ + readonly perps_market?: string; + /** Preset chip value, when amount_selection_method is preset. */ + readonly preset_value?: 10 | 50 | 100 | 250; + /** Slider position (0–100), when amount_selection_method is slider. */ + readonly slider_percent?: number; + /** Surface from which the QuickBuy sheet was opened. */ + readonly source: + | 'notification' + | 'profile_position' + | 'leaderboard' + | 'trader_feed' + | 'asset_details' + | 'market_insights' + | 'security_trust' + | 'explore_search' + | 'explore_crypto' + | 'explore_now' + | 'explore_rwas' + | 'explore_trending' + | 'explore_stocks'; + /** Wallet address of the trader. May be absent on cold-start notification deeplinks where only a profile UUID is available. */ + readonly trader_address?: string; +} + +export const createQuickBuyAmountSelected = ( + transport: AnalyticsEventTransport, +) => ({ + v1: createEventVersion( + transport, + EVENT_NAME, + 1, + ), + v2: createEventVersion( + transport, + EVENT_NAME, + 2, + ), +}); diff --git a/docs/readme/typed-analytics-contracts.md b/docs/readme/typed-analytics-contracts.md new file mode 100644 index 000000000000..ecf657c5d62e --- /dev/null +++ b/docs/readme/typed-analytics-contracts.md @@ -0,0 +1,146 @@ +# Typed analytics contracts + +## Purpose + +Mobile analytics contracts are produced from the rules deployed to Segment. +The deployed contract release is the source of truth for event names, +versions, properties, primitive types, and enums. + +This work adds a client-side verification and generation path without changing +Mobile's production analytics dispatch. + +## Contract lifecycle + +```text +segment-schema change + -> Segment deployment + -> private immutable-by-convention contract release + -> Mobile downloads metamask-mobile.json + -> Mobile verifies hashes and selected event versions + -> Mobile generates and type-checks the pilot API +``` + +The schema repository release contains: + +- `manifest.json` — provenance, tracking-plan IDs, rule counts, and semantic + hashes. +- `metamask-mobile.json` — the deployed Mobile contract. +- `metamask-extension.json` — the deployed Extension contract. + +Mobile must use an exact release tag and verify both the release asset digest +and the semantic contract hash. It must not consume a mutable `latest` asset in +application builds. + +The raw contract is private. It is downloaded only to temporary local or CI +storage and must not be committed or included in generated artifacts unless +the visibility policy changes. + +## Local verification + +Prerequisites: + +- Node and Yarn versions from `.nvmrc` and `package.json`. +- `gh` authenticated with read access to `Consensys/segment-schema`. + +Check the newest published contract: + +```sh +yarn analytics:contract:check +``` + +Check a specific release: + +```sh +yarn analytics:contract:check \ + --release-tag analytics-contracts-67390efdfde3-8db9e9f80441 +``` + +Check the pinned contract release: + +```sh +yarn analytics:contract:check:locked +``` + +Local runs retain the downloaded contract and generated pilot in the +gitignored `temp/local-analytics-contract-check/` directory by default. Use +`--output-directory` to choose another location. + +The command: + +1. Resolves the newest published analytics release, or the requested tag. +2. Downloads `manifest.json` and `metamask-mobile.json`. +3. Verifies source provenance, platform, tracking-plan ID, rule count, asset + digest, manifest hash, and semantic hash. +4. Selects Quick Buy Amount Selected v1 and v2. +5. Generates a temporary strict TypeScript facade. +6. Type-checks valid and compile-fail fixtures. +7. Retains local files in the default temporary directory; CI-provided input + paths are cleaned unless an output directory or `--keep` is used. + +The pinned metadata in +[`contract.lock.json`](../../scripts/analytics/typed-events/contract.lock.json) +contains only public release identifiers and hashes. It does not contain the +private contract files. + +The reviewed Quick Buy facade is public and tracked at +[`QuickBuyAmountSelected.ts`](../../app/util/analytics/generated/QuickBuyAmountSelected.ts). +CI regenerates the facade from the downloaded contract and fails if the tracked +types drift from the deployed contract. + +## Generator decision + +The Quick Buy pilot uses the narrow strict generator. It preserves explicit +event versions, rejects unknown properties, and adds the event-version context. +The generator comparison was a time-boxed design spike; its temporary +comparison tooling is not part of the production dependency graph. + +## Quick Buy pilot and versions + +The pilot event family is `Quick Buy Amount Selected`. + +The same Segment event name has multiple schema versions: + +- v1 supports `preset` and `custom_input`. +- v2 adds `slider`, `slider_percent`, `trader_feed`, and changed preset + values. +- Both versions currently require `amount_usd`, + `amount_selection_method`, and `source`. + +The generated API requires explicit version selection: + +```ts +QuickBuyAmountSelected.v1.track(properties); +QuickBuyAmountSelected.v2.track(properties); +``` + +The generated wrapper also supplies +`context.protocols.event_version`. Existing Mobile calls do not use this +facade yet, so production behavior is unchanged. + +## Generator boundary + +The deployed contract currently flattens event-owned properties together with +SDK and destination properties. The pilot therefore uses an explicit reviewed +allowlist in +[`quick-buy-pilot.ts`](../../scripts/analytics/typed-events/quick-buy-pilot.ts). + +The narrow generator supports the pilot's primitive types, enums, required and +optional properties, exact object properties, and explicit versions. It fails +on unsupported JSON Schema features instead of silently generating an +incomplete type. + +This is intentional. A general generator must first establish property +provenance and a broader JSON Schema support matrix. + +## Planned progression + +1. Merge and observe the incubation workflow. +2. Review the pinned lock and generator decision. +3. Add optional `AnalyticsContext` plumbing through Mobile's existing analytics + queue and adapter. +4. Integrate the generated facade into one reviewed Quick Buy production call. +5. Expand the generic generator and event coverage incrementally. + +The analytics controller remains a generic runtime transport. Event-specific +property typing belongs in the generated Mobile facade, not in +`@metamask/analytics-controller`. diff --git a/package.json b/package.json index 64dcadae435c..b1114a48a407 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,8 @@ "build:attribution": "./scripts/generate-attributions.sh", "test": "yarn test:unit", "test:unit": "jest ./app/ ./locales/ ./tests/**/*.test.ts .github/**/*.test.ts ./scripts/**/*.test.ts --testPathIgnorePatterns='.*/tests/smoke-appium/.*\\.spec\\.(ts|tsx|js)$|.*/e2e/.*\\.spec\\.(ts|tsx|js)$|.*\\.integration\\.test\\.(ts|tsx|js|jsx)$|.*/tests/llm-workflow/.*\\.test\\.(ts|tsx|js)$|.*\\.view(\\..*)?\\.test\\.(ts|tsx|js|jsx)$'", + "analytics:contract:check": "tsx scripts/analytics/typed-events/check-contract.ts", + "analytics:contract:check:locked": "tsx scripts/analytics/typed-events/check-contract.ts --lock-file scripts/analytics/typed-events/contract.lock.json", "test:unit:llm-workflow": "jest -c jest.config.llm-workflow.js", "test:smart-e2e": "tsx --test .ai-pr-analyzer/modes/smart-e2e/*.test.ts .github/actions/smart-e2e-selection/*.test.mjs", "mm:doctor": "tsx tests/llm-workflow/doctor-entry.ts", diff --git a/scripts/analytics/typed-events/README.md b/scripts/analytics/typed-events/README.md new file mode 100644 index 000000000000..579f0b337ceb --- /dev/null +++ b/scripts/analytics/typed-events/README.md @@ -0,0 +1,54 @@ +# Typed analytics contract check + +This tooling downloads the deployed Mobile analytics contract, verifies its +release metadata, generates the Quick Buy v1/v2 pilot in a temporary directory, +and type-checks the generated API. Local runs retain these files in the +gitignored `temp/local-analytics-contract-check/` directory by default. + +It does not modify application code. CI runs that receive already-downloaded +paths clean their generated temporary files unless `--keep` or +`--output-directory` is provided. + +## Local usage + +The local command uses the GitHub CLI authentication already configured on the +machine. Confirm access first: + +```sh +gh auth status +``` + +Check the newest published analytics release: + +```sh +yarn analytics:contract:check +``` + +Check a specific release: + +```sh +yarn analytics:contract:check \ + --release-tag analytics-contracts-67390efdfde3-8db9e9f80441 +``` + +Check the pinned release metadata: + +```sh +yarn analytics:contract:check:locked +``` + +Use a different directory for the retained files: + +```sh +yarn analytics:contract:check \ + --output-directory temp/my-analytics-contract-check +``` + +The reviewed Quick Buy facade is tracked publicly under +`app/util/analytics/generated/`. CI regenerates it and compares the result to +the tracked files. The `.test-d.ts` file checks compile-time contracts; the +generated `.test.ts` file covers runtime event-name and version-context +forwarding. + +The command fails if release verification, Quick Buy v1/v2 generation, or the +generated TypeScript checks fail. diff --git a/scripts/analytics/typed-events/check-contract.test.ts b/scripts/analytics/typed-events/check-contract.test.ts new file mode 100644 index 000000000000..69341eff16b3 --- /dev/null +++ b/scripts/analytics/typed-events/check-contract.test.ts @@ -0,0 +1,59 @@ +import { + flattenReleasePages, + selectLatestAnalyticsRelease, + type AnalyticsContractRelease, +} from './check-contract'; + +const createRelease = ( + tag: string, + publishedAt: string, +): AnalyticsContractRelease => ({ + assets: [], + publishedAt, + tag, + url: `https://github.com/Consensys/segment-schema/releases/tag/${tag}`, +}); + +describe('selectLatestAnalyticsRelease', () => { + it('selects the newest analytics contract release', () => { + const releases = [ + createRelease( + 'analytics-contracts-old', + '2026-09-25T14:00:00.000Z', + ), + createRelease( + 'other-release', + '2026-09-25T16:00:00.000Z', + ), + createRelease( + 'analytics-contracts-new', + '2026-09-25T15:00:00.000Z', + ), + ]; + + const result = selectLatestAnalyticsRelease(releases); + + expect(result.tag).toBe('analytics-contracts-new'); + }); + + it('rejects a release list without analytics contract releases', () => { + const releases = [createRelease('other-release', '2026-09-25T16:00:00.000Z')]; + + expect(() => selectLatestAnalyticsRelease(releases)).toThrow( + 'No published releases with prefix', + ); + }); +}); + +describe('flattenReleasePages', () => { + it('combines all paginated release pages', () => { + const releases = [ + [{ tag_name: 'first' }], + [{ tag_name: 'second' }], + ]; + + const result = flattenReleasePages(releases); + + expect(result).toEqual([{ tag_name: 'first' }, { tag_name: 'second' }]); + }); +}); diff --git a/scripts/analytics/typed-events/check-contract.ts b/scripts/analytics/typed-events/check-contract.ts new file mode 100644 index 000000000000..eaae6b865c33 --- /dev/null +++ b/scripts/analytics/typed-events/check-contract.ts @@ -0,0 +1,533 @@ +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { format } from 'prettier'; +import { + verifyMobileContract, + type VerifiedMobileContract, +} from './contract'; +import { + assertContractMatchesLock, + readContractLock, + type ContractLock, +} from './contract-lock'; +import { generateEventPilot } from './generator'; +import { QUICK_BUY_AMOUNT_SELECTED_PILOT } from './quick-buy-pilot'; + +const CONTRACT_REPOSITORY = 'Consensys/segment-schema'; +const ANALYTICS_RELEASE_PREFIX = 'analytics-contracts-'; +const DEFAULT_LOCAL_OUTPUT_DIRECTORY = join( + process.cwd(), + 'temp', + 'local-analytics-contract-check', +); +const log = (message: string): void => { + console.error(`[analytics-contract] ${message}`); +}; + +interface ReleaseAsset { + readonly digest: string | null; + readonly name: string; +} + +export interface AnalyticsContractRelease { + readonly assets: readonly ReleaseAsset[]; + readonly publishedAt: string; + readonly tag: string; + readonly url: string; +} + +export interface ContractCheckOptions { + readonly assetSha256?: string; + readonly contractPath?: string; + readonly keep: boolean; + readonly jsonOutput?: string; + readonly lockFilePath?: string; + readonly manifestPath?: string; + readonly outputDirectory?: string; + readonly releaseTag?: string; + readonly releaseUrl?: string; +} + +export interface ContractCheckResult { + readonly generated: { + readonly eventName: string; + readonly versions: readonly number[]; + }; + readonly release: { + readonly publishedAt: string; + readonly tag: string; + readonly url: string; + }; + readonly verification: { + readonly assetSha256: string; + readonly contractHash: string; + readonly ruleCount: number; + readonly sourceSha: string; + readonly trackingPlanId: string; + }; + readonly workDirectory?: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const readString = ( + object: Record, + key: string, + path: string, +): string => { + const value = object[key]; + + if (typeof value !== 'string') { + throw new Error(`Expected string at ${path}.${key}`); + } + + return value; +}; + +const readRelease = (value: unknown, path: string): AnalyticsContractRelease => { + if (!isRecord(value)) { + throw new Error(`Expected release object at ${path}`); + } + + const assetsValue = value.assets; + + if (!Array.isArray(assetsValue)) { + throw new Error(`Expected release assets array at ${path}.assets`); + } + + const assets = assetsValue.map((asset, index) => { + const assetPath = `${path}.assets[${index}]`; + + if (!isRecord(asset)) { + throw new Error(`Expected release asset object at ${assetPath}`); + } + + const digest = asset.digest; + + if (digest !== null && typeof digest !== 'string') { + throw new Error(`Expected string or null at ${assetPath}.digest`); + } + + return { + digest, + name: readString(asset, 'name', assetPath), + }; + }); + + return { + assets, + publishedAt: readString(value, 'published_at', path), + tag: readString(value, 'tag_name', path), + url: readString(value, 'html_url', path), + }; +}; + +const runGhJson = (args: readonly string[]): unknown => { + const output = execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + + return JSON.parse(output) as unknown; +}; + +/** + * Select the newest published analytics contract release. + * + * @param releases - Release metadata returned by GitHub. + * @returns The newest analytics contract release. + */ +export const selectLatestAnalyticsRelease = ( + releases: readonly AnalyticsContractRelease[], +): AnalyticsContractRelease => { + const candidates = releases + .filter((release) => release.tag.startsWith(ANALYTICS_RELEASE_PREFIX)) + .slice() + .sort( + (left, right) => + Date.parse(right.publishedAt) - Date.parse(left.publishedAt), + ); + const [latest] = candidates; + + if (!latest) { + throw new Error( + `No published releases with prefix '${ANALYTICS_RELEASE_PREFIX}' were found`, + ); + } + + return latest; +}; + +/** + * Flatten the paginated GitHub releases response produced by `gh api + * --paginate --slurp`. + * + * @param value - GitHub API response pages. + * @returns Release values from all pages. + */ +export const flattenReleasePages = (value: unknown): readonly unknown[] => { + if (!Array.isArray(value)) { + throw new Error('Expected GitHub releases response to be an array'); + } + + if (value.every((page) => Array.isArray(page))) { + return value.flat(); + } + + return value; +}; + +const resolveRelease = ( + requestedTag: string | undefined, +): AnalyticsContractRelease => { + if (requestedTag) { + const releaseValue = runGhJson([ + 'api', + `repos/${CONTRACT_REPOSITORY}/releases/tags/${requestedTag}`, + ]); + + if (!isRecord(releaseValue)) { + throw new Error(`Expected release object for '${requestedTag}'`); + } + + const release = readRelease( + releaseValue, + `release ${requestedTag}`, + ); + + if (!release.tag.startsWith(ANALYTICS_RELEASE_PREFIX)) { + throw new Error( + `Release ${release.tag} is not an analytics contract release`, + ); + } + + if (releaseValue.draft !== false || releaseValue.prerelease !== false) { + throw new Error(`Release ${release.tag} is not published`); + } + + return release; + } + + const releasesValue = runGhJson([ + 'api', + '--paginate', + '--slurp', + `repos/${CONTRACT_REPOSITORY}/releases?per_page=100`, + ]); + const releases = flattenReleasePages(releasesValue); + + const parsedReleases = releases + .filter((release) => isRecord(release)) + .filter((release) => release.draft === false && release.prerelease === false) + .map((release, index) => readRelease(release, `releases[${index}]`)); + + return selectLatestAnalyticsRelease(parsedReleases); +}; + +const readAssetDigest = (release: AnalyticsContractRelease): string => { + const asset = release.assets.find( + ({ name }) => name === 'metamask-mobile.json', + ); + + if (!asset?.digest || !/^sha256:[0-9a-f]{64}$/u.test(asset.digest)) { + throw new Error( + `Release ${release.tag} has no valid metamask-mobile.json SHA-256 digest`, + ); + } + + return asset.digest; +}; + +const downloadRelease = ( + release: AnalyticsContractRelease, + directory: string, +): void => { + mkdirSync(directory, { recursive: true }); + rmSync(join(directory, 'manifest.json'), { force: true }); + rmSync(join(directory, 'metamask-mobile.json'), { force: true }); + execFileSync( + 'gh', + [ + 'release', + 'download', + release.tag, + '--repo', + CONTRACT_REPOSITORY, + '--pattern', + 'manifest.json', + '--pattern', + 'metamask-mobile.json', + '--dir', + directory, + ], + { stdio: 'inherit' }, + ); +}; + +const writeGeneratedFiles = async ( + verifiedContract: VerifiedMobileContract, + outputDirectory: string, +): Promise => { + const generated = generateEventPilot( + verifiedContract.eventRules, + QUICK_BUY_AMOUNT_SELECTED_PILOT, + ); + + mkdirSync(outputDirectory, { recursive: true }); + writeFileSync( + join(outputDirectory, 'QuickBuyAmountSelected.ts'), + await format(generated.source, { + parser: 'typescript', + singleQuote: true, + filepath: join(outputDirectory, 'QuickBuyAmountSelected.ts'), + }), + ); + writeFileSync( + join(outputDirectory, 'QuickBuyAmountSelected.test-d.ts'), + await format(generated.typeTests, { + parser: 'typescript', + singleQuote: true, + filepath: join(outputDirectory, 'QuickBuyAmountSelected.test-d.ts'), + }), + ); +}; + +const typeCheckGeneratedFiles = (directory: string): void => { + const yarnCommand = process.platform === 'win32' ? 'yarn.cmd' : 'yarn'; + + execFileSync( + yarnCommand, + [ + 'tsc', + '--noEmit', + '--skipLibCheck', + '--target', + 'esnext', + '--module', + 'preserve', + '--moduleResolution', + 'bundler', + '--strict', + join(directory, 'QuickBuyAmountSelected.ts'), + join(directory, 'QuickBuyAmountSelected.test-d.ts'), + ], + { stdio: 'inherit' }, + ); +}; + +const readOptions = (args: readonly string[]): ContractCheckOptions => { + const options = new Map(); + + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + + if (!argument?.startsWith('--')) { + throw new Error(`Unexpected argument '${argument ?? ''}'`); + } + + if (argument === '--keep') { + options.set('keep', true); + continue; + } + + const value = args[index + 1]; + + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for '${argument}'`); + } + + options.set(argument.slice(2), value); + index++; + } + + const outputDirectory = options.get('output-directory'); + const jsonOutput = options.get('json-output'); + const lockFilePath = options.get('lock-file'); + const assetSha256 = options.get('asset-sha256'); + const contractPath = options.get('contract'); + const manifestPath = options.get('manifest'); + const releaseTag = options.get('release-tag'); + const releaseUrl = options.get('release-url'); + + const localInputs = [assetSha256, contractPath, manifestPath].filter( + (value) => value !== undefined, + ); + + if (localInputs.length !== 0 && localInputs.length !== 3) { + throw new Error( + 'The --manifest, --contract, and --asset-sha256 options must be provided together', + ); + } + + return { + assetSha256: typeof assetSha256 === 'string' ? assetSha256 : undefined, + contractPath: typeof contractPath === 'string' ? contractPath : undefined, + keep: options.get('keep') === true, + jsonOutput: typeof jsonOutput === 'string' ? jsonOutput : undefined, + lockFilePath: + typeof lockFilePath === 'string' ? lockFilePath : undefined, + manifestPath: typeof manifestPath === 'string' ? manifestPath : undefined, + outputDirectory: + typeof outputDirectory === 'string' ? outputDirectory : undefined, + releaseTag: typeof releaseTag === 'string' ? releaseTag : undefined, + releaseUrl: typeof releaseUrl === 'string' ? releaseUrl : undefined, + }; +}; + +/** + * Download, verify, generate, and type-check the latest Mobile contract. + * + * @param options - Release selection and temporary-output options. + * @returns A sanitized result suitable for CI summaries. + */ +export const checkAnalyticsContract = async ( + options: ContractCheckOptions, +): Promise => { + const contractLock: ContractLock | undefined = options.lockFilePath + ? readContractLock(options.lockFilePath) + : undefined; + const usesProvidedContract = + options.manifestPath !== undefined && + options.contractPath !== undefined && + options.assetSha256 !== undefined; + if ( + contractLock && + options.releaseTag !== undefined && + options.releaseTag !== contractLock.releaseTag + ) { + throw new Error( + `Release tag '${options.releaseTag}' does not match contract lock release '${contractLock.releaseTag}'`, + ); + } + const requestedReleaseTag = options.releaseTag ?? contractLock?.releaseTag; + const release = usesProvidedContract + ? { + assets: [], + publishedAt: '', + tag: requestedReleaseTag ?? 'local', + url: options.releaseUrl ?? '', + } + : resolveRelease(requestedReleaseTag); + const assetDigest = usesProvidedContract + ? options.assetSha256 + : readAssetDigest(release); + const workDirectory = + options.outputDirectory ?? + (usesProvidedContract + ? mkdtempSync(join(tmpdir(), 'metamask-mobile-analytics-')) + : DEFAULT_LOCAL_OUTPUT_DIRECTORY); + const contractDirectory = join(workDirectory, 'contract'); + const generatedDirectory = join(workDirectory, 'generated'); + const shouldKeepWorkDirectory = + options.keep || + options.outputDirectory !== undefined || + !usesProvidedContract; + + try { + if (contractLock) { + log( + `Using contract lock '${options.lockFilePath}' for release '${contractLock.releaseTag}'.`, + ); + } + + if (usesProvidedContract) { + log( + `Using the contract downloaded by the shared action for release '${release.tag}'; skipping download.`, + ); + } else { + log(`Downloading analytics contract release '${release.tag}'.`); + downloadRelease(release, contractDirectory); + } + + if ( + contractLock && + assetDigest !== undefined && + assetDigest !== contractLock.assetSha256 + ) { + throw new Error( + `Contract lock mismatch for assetSha256: expected '${contractLock.assetSha256}', got '${assetDigest}'`, + ); + } + + const verifiedContract = verifyMobileContract({ + manifestPath: + options.manifestPath ?? join(contractDirectory, 'manifest.json'), + contractPath: + options.contractPath ?? join(contractDirectory, 'metamask-mobile.json'), + expectedAssetSha256: assetDigest, + eventName: QUICK_BUY_AMOUNT_SELECTED_PILOT.eventName, + versions: QUICK_BUY_AMOUNT_SELECTED_PILOT.versions.map( + ({ version }) => version, + ), + }); + + if (contractLock) { + assertContractMatchesLock(contractLock, { + assetSha256: verifiedContract.assetSha256, + contract: verifiedContract.contract, + releaseTag: release.tag, + releaseUrl: release.url, + }); + } + + await writeGeneratedFiles(verifiedContract, generatedDirectory); + typeCheckGeneratedFiles(generatedDirectory); + + const result: ContractCheckResult = { + generated: { + eventName: QUICK_BUY_AMOUNT_SELECTED_PILOT.eventName, + versions: QUICK_BUY_AMOUNT_SELECTED_PILOT.versions.map( + ({ version }) => version, + ), + }, + release: { + publishedAt: release.publishedAt, + tag: release.tag, + url: release.url, + }, + verification: { + assetSha256: verifiedContract.assetSha256, + contractHash: verifiedContract.contract.hash, + ruleCount: verifiedContract.contract.ruleCount, + sourceSha: verifiedContract.manifest.sourceSha, + trackingPlanId: verifiedContract.contract.trackingPlan.id, + }, + ...(shouldKeepWorkDirectory ? { workDirectory } : {}), + }; + + if (options.jsonOutput) { + writeFileSync(options.jsonOutput, `${JSON.stringify(result, null, 2)}\n`); + } + + if (shouldKeepWorkDirectory) { + log(`Retained analytics contract artifacts at '${workDirectory}'.`); + } + + return result; + } finally { + if (!shouldKeepWorkDirectory) { + log('Cleaning temporary analytics contract artifacts.'); + rmSync(workDirectory, { recursive: true, force: true }); + } + } +}; + +const main = async (): Promise => { + const result = await checkAnalyticsContract(readOptions(process.argv.slice(2))); + console.log(JSON.stringify(result, null, 2)); +}; + +if (process.argv[1]?.endsWith('check-contract.ts')) { + main().catch((error: unknown) => { + console.error(String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/analytics/typed-events/contract-lock.test.ts b/scripts/analytics/typed-events/contract-lock.test.ts new file mode 100644 index 000000000000..91b24ed7977e --- /dev/null +++ b/scripts/analytics/typed-events/contract-lock.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + assertContractMatchesLock, + readContractLock, + type ContractLock, + type ContractLockVerificationInput, +} from './contract-lock'; + +const createLock = (): ContractLock => ({ + assetName: 'metamask-mobile.json', + assetSha256: `sha256:${'a'.repeat(64)}`, + contractFormatVersion: 1, + contractHash: `sha256:${'b'.repeat(64)}`, + lockVersion: 1, + platform: 'mobile', + releaseTag: 'analytics-contracts-test', + releaseUrl: + 'https://github.com/Consensys/segment-schema/releases/tag/analytics-contracts-test', + ruleCount: 2, + sourceSha: 'a'.repeat(40), + trackingPlanId: 'tp_test', +}); + +const createInput = (): ContractLockVerificationInput => ({ + assetSha256: `sha256:${'a'.repeat(64)}`, + contract: { + formatVersion: 1, + hash: `sha256:${'b'.repeat(64)}`, + platform: 'mobile', + ruleCount: 2, + sourceSha: 'a'.repeat(40), + trackingPlan: { + id: 'tp_test', + }, + }, + releaseTag: 'analytics-contracts-test', + releaseUrl: + 'https://github.com/Consensys/segment-schema/releases/tag/analytics-contracts-test', +}); + +describe('contract lock', () => { + const roots: string[] = []; + + afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); + }); + + it('reads valid lock metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'analytics-contract-lock-')); + roots.push(root); + const filePath = join(root, 'contract.lock.json'); + const lock = createLock(); + + writeFileSync(filePath, JSON.stringify(lock)); + + expect(readContractLock(filePath)).toEqual(lock); + }); + + it('rejects a lock for a non-Mobile asset', () => { + const root = mkdtempSync(join(tmpdir(), 'analytics-contract-lock-')); + roots.push(root); + const filePath = join(root, 'contract.lock.json'); + + writeFileSync( + filePath, + JSON.stringify({ + ...createLock(), + assetName: 'metamask-extension.json', + }), + ); + + expect(() => readContractLock(filePath)).toThrow( + 'Expected Mobile contract asset', + ); + }); + + it('rejects a verified contract that differs from the lock', () => { + const lock = createLock(); + const input = { + ...createInput(), + contract: { + ...createInput().contract, + ruleCount: 3, + }, + }; + + expect(() => assertContractMatchesLock(lock, input)).toThrow( + 'Contract lock mismatch for ruleCount', + ); + }); +}); diff --git a/scripts/analytics/typed-events/contract-lock.ts b/scripts/analytics/typed-events/contract-lock.ts new file mode 100644 index 000000000000..44f69917245c --- /dev/null +++ b/scripts/analytics/typed-events/contract-lock.ts @@ -0,0 +1,193 @@ +import { readFileSync } from 'node:fs'; +import type { MobileContract, TrackingPlanMetadata } from './contract'; + +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const SOURCE_SHA_PATTERN = /^[0-9a-f]{40}$/u; +const RELEASE_PREFIX = 'analytics-contracts-'; + +export interface ContractLock { + readonly assetName: 'metamask-mobile.json'; + readonly assetSha256: string; + readonly contractFormatVersion: number; + readonly contractHash: string; + readonly lockVersion: 1; + readonly platform: 'mobile'; + readonly releaseTag: string; + readonly releaseUrl: string; + readonly ruleCount: number; + readonly sourceSha: string; + readonly trackingPlanId: string; +} + +export interface ContractLockVerificationInput { + readonly assetSha256: string; + readonly contract: Pick< + MobileContract, + | 'formatVersion' + | 'platform' + | 'sourceSha' + | 'ruleCount' + | 'hash' + > & { + readonly trackingPlan: Pick; + }; + readonly releaseTag: string; + readonly releaseUrl: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const readString = ( + object: Record, + key: string, + path: string, +): string => { + const value = object[key]; + + if (typeof value !== 'string') { + throw new Error(`Expected string at ${path}.${key}`); + } + + return value; +}; + +const readInteger = ( + object: Record, + key: string, + path: string, +): number => { + const value = object[key]; + + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new Error(`Expected integer at ${path}.${key}`); + } + + return value; +}; + +const readJsonObject = (filePath: string): Record => { + let parsed: unknown; + + try { + parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; + } catch (error) { + throw new Error(`Unable to parse contract lock '${filePath}': ${String(error)}`); + } + + if (!isRecord(parsed)) { + throw new Error(`Expected contract lock '${filePath}' to contain an object`); + } + + return parsed; +}; + +const readSha256 = ( + object: Record, + key: string, + path: string, +): string => { + const value = readString(object, key, path); + + if (!SHA256_PATTERN.test(value)) { + throw new Error(`Expected SHA-256 digest at ${path}.${key}`); + } + + return value; +}; + +const readSourceSha = ( + object: Record, + key: string, + path: string, +): string => { + const value = readString(object, key, path); + + if (!SOURCE_SHA_PATTERN.test(value)) { + throw new Error(`Expected Git commit SHA at ${path}.${key}`); + } + + return value; +}; + +/** + * Read and validate public metadata for one immutable Mobile contract release. + * + * @param filePath - Path to the lock metadata. + * @returns The validated lock. + */ +export const readContractLock = (filePath: string): ContractLock => { + const object = readJsonObject(filePath); + const path = 'contractLock'; + const lockVersion = readInteger(object, 'lockVersion', path); + const assetName = readString(object, 'assetName', path); + const platform = readString(object, 'platform', path); + const releaseTag = readString(object, 'releaseTag', path); + + if (lockVersion !== 1) { + throw new Error(`Unsupported contract lock version '${lockVersion}'`); + } + + if (assetName !== 'metamask-mobile.json') { + throw new Error(`Expected Mobile contract asset, got '${assetName}'`); + } + + if (platform !== 'mobile') { + throw new Error(`Expected Mobile contract platform, got '${platform}'`); + } + + if (!releaseTag.startsWith(RELEASE_PREFIX)) { + throw new Error(`Expected analytics contract release tag, got '${releaseTag}'`); + } + + return { + assetName, + assetSha256: readSha256(object, 'assetSha256', path), + contractFormatVersion: readInteger(object, 'contractFormatVersion', path), + contractHash: readSha256(object, 'contractHash', path), + lockVersion: 1, + platform: 'mobile', + releaseTag, + releaseUrl: readString(object, 'releaseUrl', path), + ruleCount: readInteger(object, 'ruleCount', path), + sourceSha: readSourceSha(object, 'sourceSha', path), + trackingPlanId: readString(object, 'trackingPlanId', path), + }; +}; + +const assertEqual = ( + label: string, + actual: string | number, + expected: string | number, +): void => { + if (actual !== expected) { + throw new Error( + `Contract lock mismatch for ${label}: expected '${expected}', got '${actual}'`, + ); + } +}; + +/** + * Verify downloaded release metadata and contract content against a lock. + * + * @param lock - Expected immutable release metadata. + * @param input - Resolved release and verified contract values. + */ +export const assertContractMatchesLock = ( + lock: ContractLock, + input: ContractLockVerificationInput, +): void => { + assertEqual('releaseTag', input.releaseTag, lock.releaseTag); + assertEqual('releaseUrl', input.releaseUrl, lock.releaseUrl); + assertEqual('assetSha256', input.assetSha256, lock.assetSha256); + assertEqual( + 'contractFormatVersion', + input.contract.formatVersion, + lock.contractFormatVersion, + ); + assertEqual('platform', input.contract.platform, lock.platform); + assertEqual('sourceSha', input.contract.sourceSha, lock.sourceSha); + assertEqual('trackingPlanId', input.contract.trackingPlan.id, lock.trackingPlanId); + assertEqual('ruleCount', input.contract.ruleCount, lock.ruleCount); + assertEqual('contractHash', input.contract.hash, lock.contractHash); +}; diff --git a/scripts/analytics/typed-events/contract.lock.json b/scripts/analytics/typed-events/contract.lock.json new file mode 100644 index 000000000000..11c67b63b130 --- /dev/null +++ b/scripts/analytics/typed-events/contract.lock.json @@ -0,0 +1,13 @@ +{ + "lockVersion": 1, + "assetName": "metamask-mobile.json", + "assetSha256": "sha256:9e07f2ac4c23fa37fb69001364f79981b7f7c3fcb126ee1e39ab457f02ecead7", + "contractFormatVersion": 1, + "contractHash": "sha256:f75e241c1a832b22c80f9537765e51dac0ba380133f1ec90a6f3e7c77235c4c3", + "platform": "mobile", + "releaseTag": "analytics-contracts-67390efdfde3-8db9e9f80441", + "releaseUrl": "https://github.com/Consensys/segment-schema/releases/tag/analytics-contracts-67390efdfde3-8db9e9f80441", + "ruleCount": 784, + "sourceSha": "67390efdfde300fec50596c149eb02e46f91c5eb", + "trackingPlanId": "tp_2iOKYXh3y11VP5nMhQJAfB5McDH" +} diff --git a/scripts/analytics/typed-events/contract.test.ts b/scripts/analytics/typed-events/contract.test.ts new file mode 100644 index 000000000000..5ee8f21c0334 --- /dev/null +++ b/scripts/analytics/typed-events/contract.test.ts @@ -0,0 +1,228 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + computeSemanticContractHash, + sha256File, + verifyMobileContract, + type ContractManifest, + type ContractRule, + type MobileContract, +} from './contract'; + +const EVENT_NAME = 'Quick Buy Amount Selected'; +const SOURCE_SHA = '67390efdfde300fec50596c149eb02e46f91c5eb'; +const TRACKING_PLAN_ID = 'tp_mobile_test'; + +interface FixtureOptions { + readonly versions?: readonly number[]; + readonly deprecatedVersions?: readonly number[]; +} + +interface ContractFixture { + readonly root: string; + readonly manifestPath: string; + readonly contractPath: string; + readonly assetSha256: string; + readonly manifest: ContractManifest; + readonly contract: MobileContract; +} + +const createRule = ( + version: number, + deprecatedAt: string | null, +): ContractRule => ({ + key: EVENT_NAME, + type: 'TRACK', + version, + jsonSchema: { + type: 'object', + properties: { + properties: { + type: 'object', + properties: { + amount_usd: { type: ['number'] }, + amount_selection_method: { + type: ['string'], + enum: ['preset', 'custom_input'], + }, + source: { type: ['string'] }, + }, + required: ['amount_usd', 'amount_selection_method', 'source'], + }, + }, + }, + createdAt: null, + updatedAt: null, + deprecatedAt, +}); + +const createFixture = ({ + versions = [1, 2], + deprecatedVersions = [], +}: FixtureOptions = {}): ContractFixture => { + const root = mkdtempSync(join(tmpdir(), 'typed-analytics-contract-')); + const manifestPath = join(root, 'manifest.json'); + const contractPath = join(root, 'metamask-mobile.json'); + const rules = versions.map((version) => + createRule( + version, + deprecatedVersions.includes(version) + ? '2026-09-25T00:00:00.000Z' + : null, + ), + ); + const contractWithoutHash = { + formatVersion: 1, + platform: 'mobile' as const, + sourceSha: SOURCE_SHA, + generatedAt: '2026-09-25T00:00:00.000Z', + trackingPlan: { + id: TRACKING_PLAN_ID, + name: 'MetaMask Mobile', + type: 'LIVE', + updatedAt: '2026-09-25T00:00:00.000Z', + }, + ruleCount: rules.length, + rules, + }; + const contract: MobileContract = { + ...contractWithoutHash, + hash: computeSemanticContractHash(contractWithoutHash), + }; + const manifest: ContractManifest = { + formatVersion: 1, + source: 'segment-public-api', + sourceRepository: 'Consensys/segment-schema', + sourceSha: SOURCE_SHA, + generatedAt: '2026-09-25T00:00:00.000Z', + contracts: { + mobile: { + slug: 'metamask-mobile', + trackingPlanId: TRACKING_PLAN_ID, + trackingPlanUpdatedAt: '2026-09-25T00:00:00.000Z', + ruleCount: rules.length, + pageCount: 1, + hash: contract.hash, + }, + }, + }; + + writeFileSync(contractPath, JSON.stringify(contract)); + writeFileSync(manifestPath, JSON.stringify(manifest)); + + return { + root, + manifestPath, + contractPath, + assetSha256: sha256File(contractPath), + manifest, + contract, + }; +}; + +const cleanupFixture = (fixture: ContractFixture): void => { + rmSync(fixture.root, { recursive: true, force: true }); +}; + +describe('verifyMobileContract', () => { + const fixtures: ContractFixture[] = []; + + afterEach(() => { + fixtures.splice(0).forEach(cleanupFixture); + }); + + it('returns both requested Quick Buy versions for a verified Mobile contract', () => { + const fixture = createFixture(); + fixtures.push(fixture); + + const result = verifyMobileContract({ + manifestPath: fixture.manifestPath, + contractPath: fixture.contractPath, + expectedAssetSha256: fixture.assetSha256, + }); + + expect(result.eventRules.map((rule) => rule.version)).toEqual([1, 2]); + expect(result.assetSha256).toBe(fixture.assetSha256); + }); + + it('rejects a manifest hash that differs from the contract', () => { + const fixture = createFixture(); + fixtures.push(fixture); + const mismatchedManifest: ContractManifest = { + ...fixture.manifest, + contracts: { + mobile: { + ...fixture.manifest.contracts.mobile, + hash: `sha256:${'0'.repeat(64)}`, + }, + }, + }; + + writeFileSync(fixture.manifestPath, JSON.stringify(mismatchedManifest)); + + expect(() => + verifyMobileContract({ + manifestPath: fixture.manifestPath, + contractPath: fixture.contractPath, + }), + ).toThrow('does not match contract hash'); + }); + + it('rejects semantic changes without a regenerated contract hash', () => { + const fixture = createFixture(); + fixtures.push(fixture); + const changedContract: MobileContract = { + ...fixture.contract, + rules: fixture.contract.rules.map((rule, index) => + index === 0 ? { ...rule, key: 'Different Event' } : rule, + ), + }; + + writeFileSync(fixture.contractPath, JSON.stringify(changedContract)); + + expect(() => + verifyMobileContract({ + manifestPath: fixture.manifestPath, + contractPath: fixture.contractPath, + }), + ).toThrow('does not match computed hash'); + }); + + it('rejects a missing requested event version', () => { + const fixture = createFixture({ versions: [1] }); + fixtures.push(fixture); + + expect(() => + verifyMobileContract({ + manifestPath: fixture.manifestPath, + contractPath: fixture.contractPath, + }), + ).toThrow('version 2 was not found'); + }); + + it('rejects a deprecated requested event version', () => { + const fixture = createFixture({ deprecatedVersions: [2] }); + fixtures.push(fixture); + + expect(() => + verifyMobileContract({ + manifestPath: fixture.manifestPath, + contractPath: fixture.contractPath, + }), + ).toThrow('version 2 is deprecated'); + }); + + it('rejects an asset digest mismatch', () => { + const fixture = createFixture(); + fixtures.push(fixture); + + expect(() => + verifyMobileContract({ + manifestPath: fixture.manifestPath, + contractPath: fixture.contractPath, + expectedAssetSha256: `sha256:${'0'.repeat(64)}`, + }), + ).toThrow('does not match expected digest'); + }); +}); diff --git a/scripts/analytics/typed-events/contract.ts b/scripts/analytics/typed-events/contract.ts new file mode 100644 index 000000000000..5ecd36496eaa --- /dev/null +++ b/scripts/analytics/typed-events/contract.ts @@ -0,0 +1,503 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +export type JsonValue = + | null + | boolean + | number + | string + | readonly JsonValue[] + | JsonObject; + +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +export interface ContractSummary { + readonly slug: string; + readonly trackingPlanId: string; + readonly trackingPlanUpdatedAt: string | null; + readonly ruleCount: number; + readonly pageCount: number; + readonly hash: string; +} + +export interface ContractManifest { + readonly formatVersion: number; + readonly source: string; + readonly sourceRepository: string; + readonly sourceSha: string; + readonly generatedAt: string; + readonly contracts: { + readonly mobile: ContractSummary; + }; +} + +export interface TrackingPlanMetadata { + readonly id: string; + readonly name: string; + readonly type: string; + readonly updatedAt: string | null; +} + +export interface ContractRule { + readonly key: string; + readonly type: string; + readonly version: number; + readonly jsonSchema: JsonObject; + readonly createdAt?: string | null; + readonly updatedAt?: string | null; + readonly deprecatedAt?: string | null; +} + +export interface MobileContract { + readonly formatVersion: number; + readonly platform: 'mobile'; + readonly sourceSha: string; + readonly generatedAt: string; + readonly trackingPlan: TrackingPlanMetadata; + readonly ruleCount: number; + readonly rules: readonly ContractRule[]; + readonly hash: string; +} + +export interface VerifyMobileContractOptions { + readonly manifestPath: string; + readonly contractPath: string; + readonly expectedAssetSha256?: string; + readonly eventName?: string; + readonly versions?: readonly number[]; +} + +export interface VerifiedMobileContract { + readonly manifest: ContractManifest; + readonly contract: MobileContract; + readonly eventRules: readonly ContractRule[]; + readonly assetSha256: string; +} + +const DEFAULT_EVENT_NAME = 'Quick Buy Amount Selected'; +const DEFAULT_VERSIONS = [1, 2] as const; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; + +const isJsonValue = (value: unknown): value is JsonValue => { + if (value === null) { + return true; + } + + if ( + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return true; + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + if (typeof value === 'object') { + return Object.values(value).every(isJsonValue); + } + + return false; +}; + +const isJsonObject = (value: unknown): value is JsonObject => + isJsonValue(value) && + typeof value === 'object' && + value !== null && + !Array.isArray(value); + +const readJsonObject = (filePath: string): JsonObject => { + let parsed: unknown; + + try { + parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; + } catch (error) { + throw new Error(`Unable to parse JSON file '${filePath}': ${String(error)}`); + } + + if (!isJsonObject(parsed)) { + throw new Error(`Expected JSON object in '${filePath}'`); + } + + return parsed; +}; + +const readObject = ( + value: JsonValue | undefined, + path: string, +): JsonObject => { + if (!isJsonObject(value)) { + throw new Error(`Expected object at ${path}`); + } + + return value; +}; + +const readString = ( + object: JsonObject, + key: string, + path: string, +): string => { + const value = object[key]; + + if (typeof value !== 'string') { + throw new Error(`Expected string at ${path}.${key}`); + } + + return value; +}; + +const readInteger = ( + object: JsonObject, + key: string, + path: string, +): number => { + const value = object[key]; + + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new Error(`Expected integer at ${path}.${key}`); + } + + return value; +}; + +const readNullableString = ( + object: JsonObject, + key: string, + path: string, +): string | null => { + const value = object[key]; + + if (value === null) { + return null; + } + + if (typeof value !== 'string') { + throw new Error(`Expected string or null at ${path}.${key}`); + } + + return value; +}; + +const readOptionalNullableString = ( + object: JsonObject, + key: string, + path: string, +): string | null | undefined => { + if (!(key in object)) { + return undefined; + } + + return readNullableString(object, key, path); +}; + +const readHash = ( + object: JsonObject, + key: string, + path: string, +): string => { + const value = readString(object, key, path); + + if (!SHA256_PATTERN.test(value)) { + throw new Error(`Expected SHA-256 digest at ${path}.${key}`); + } + + return value; +}; + +const parseContractSummary = ( + value: JsonValue | undefined, + path: string, +): ContractSummary => { + const object = readObject(value, path); + + return { + slug: readString(object, 'slug', path), + trackingPlanId: readString(object, 'trackingPlanId', path), + trackingPlanUpdatedAt: readNullableString( + object, + 'trackingPlanUpdatedAt', + path, + ), + ruleCount: readInteger(object, 'ruleCount', path), + pageCount: readInteger(object, 'pageCount', path), + hash: readHash(object, 'hash', path), + }; +}; + +const parseManifest = (value: JsonObject): ContractManifest => { + const contracts = readObject(value.contracts, 'manifest.contracts'); + + return { + formatVersion: readInteger(value, 'formatVersion', 'manifest'), + source: readString(value, 'source', 'manifest'), + sourceRepository: readString( + value, + 'sourceRepository', + 'manifest', + ), + sourceSha: readString(value, 'sourceSha', 'manifest'), + generatedAt: readString(value, 'generatedAt', 'manifest'), + contracts: { + mobile: parseContractSummary( + contracts.mobile, + 'manifest.contracts.mobile', + ), + }, + }; +}; + +const parseRule = (value: JsonValue, index: number): ContractRule => { + const path = `contract.rules[${index}]`; + const object = readObject(value, path); + + return { + key: readString(object, 'key', path), + type: readString(object, 'type', path), + version: readInteger(object, 'version', path), + jsonSchema: readObject(object.jsonSchema, `${path}.jsonSchema`), + createdAt: readOptionalNullableString(object, 'createdAt', path), + updatedAt: readOptionalNullableString(object, 'updatedAt', path), + deprecatedAt: readOptionalNullableString(object, 'deprecatedAt', path), + }; +}; + +const parseContract = (value: JsonObject): MobileContract => { + const platform = readString(value, 'platform', 'contract'); + + if (platform !== 'mobile') { + throw new Error(`Expected contract.platform to be 'mobile', got '${platform}'`); + } + + const rulesValue = value.rules; + + if (!Array.isArray(rulesValue)) { + throw new Error('Expected contract.rules to be an array'); + } + + const trackingPlan = readObject( + value.trackingPlan, + 'contract.trackingPlan', + ); + + return { + formatVersion: readInteger(value, 'formatVersion', 'contract'), + platform, + sourceSha: readString(value, 'sourceSha', 'contract'), + generatedAt: readString(value, 'generatedAt', 'contract'), + trackingPlan: { + id: readString(trackingPlan, 'id', 'contract.trackingPlan'), + name: readString(trackingPlan, 'name', 'contract.trackingPlan'), + type: readString(trackingPlan, 'type', 'contract.trackingPlan'), + updatedAt: readNullableString( + trackingPlan, + 'updatedAt', + 'contract.trackingPlan', + ), + }, + ruleCount: readInteger(value, 'ruleCount', 'contract'), + rules: rulesValue.map(parseRule), + hash: readHash(value, 'hash', 'contract'), + }; +}; + +const sortJsonDeep = (value: JsonValue): JsonValue => { + if (Array.isArray(value)) { + return value.map(sortJsonDeep); + } + + if (isJsonObject(value)) { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortJsonDeep(value[key])]), + ); + } + + return value; +}; + +const stableStringify = (value: JsonValue): string => + JSON.stringify(sortJsonDeep(value)); + +const sha256 = (value: string | Buffer): string => + `sha256:${createHash('sha256').update(value).digest('hex')}`; + +/** + * Calculate the SHA-256 digest for a downloaded contract asset. + * + * @param filePath - Path to the downloaded contract asset. + * @returns The digest in the release manifest format. + */ +export const sha256File = (filePath: string): string => + sha256(readFileSync(filePath)); + +/** + * Recompute the semantic hash used by the contract release producer. + * + * @param contract - Contract data without relying on its stored hash. + * @returns The recomputed semantic hash. + */ +export const computeSemanticContractHash = ( + contract: Pick< + MobileContract, + 'formatVersion' | 'platform' | 'trackingPlan' | 'rules' + >, +): string => + sha256( + stableStringify({ + formatVersion: contract.formatVersion, + platform: contract.platform, + trackingPlanId: contract.trackingPlan.id, + rules: contract.rules.map((rule) => ({ + key: rule.key, + type: rule.type, + version: rule.version, + jsonSchema: rule.jsonSchema, + deprecated: Boolean(rule.deprecatedAt), + })), + }), + ); + +const selectEventRules = ( + contract: MobileContract, + eventName: string, + versions: readonly number[], +): readonly ContractRule[] => { + const requestedVersions = [...new Set(versions)]; + + if (requestedVersions.length === 0) { + throw new Error('At least one event version is required'); + } + + return requestedVersions.map((version) => { + const matches = contract.rules.filter( + (rule) => + rule.key === eventName && + rule.type === 'TRACK' && + rule.version === version, + ); + + if (matches.length === 0) { + throw new Error( + `Event '${eventName}' version ${version} was not found in the Mobile contract`, + ); + } + + if (matches.length > 1) { + throw new Error( + `Event '${eventName}' version ${version} appears more than once in the Mobile contract`, + ); + } + + const [rule] = matches; + + if (rule.deprecatedAt) { + throw new Error( + `Event '${eventName}' version ${version} is deprecated in the Mobile contract`, + ); + } + + return rule; + }); +}; + +/** + * Verify a deployed Mobile contract and select required event versions. + * + * @param options - Paths, optional asset digest, and event selection criteria. + * @returns Verified release metadata and the selected event rules. + */ +export const verifyMobileContract = ({ + manifestPath, + contractPath, + expectedAssetSha256, + eventName = DEFAULT_EVENT_NAME, + versions = DEFAULT_VERSIONS, +}: VerifyMobileContractOptions): VerifiedMobileContract => { + const manifest = parseManifest(readJsonObject(manifestPath)); + const contract = parseContract(readJsonObject(contractPath)); + const mobileSummary = manifest.contracts.mobile; + const assetSha256 = sha256File(contractPath); + + if (manifest.formatVersion !== contract.formatVersion) { + throw new Error( + `Manifest and contract format versions differ: ${manifest.formatVersion} vs ${contract.formatVersion}`, + ); + } + + if (manifest.source !== 'segment-public-api') { + throw new Error( + `Expected manifest.source to be 'segment-public-api', got '${manifest.source}'`, + ); + } + + if (manifest.sourceRepository !== 'Consensys/segment-schema') { + throw new Error( + `Unexpected manifest.sourceRepository '${manifest.sourceRepository}'`, + ); + } + + if (contract.sourceSha !== manifest.sourceSha) { + throw new Error( + `Manifest sourceSha '${manifest.sourceSha}' does not match contract sourceSha '${contract.sourceSha}'`, + ); + } + + if (contract.ruleCount !== contract.rules.length) { + throw new Error( + `Contract ruleCount ${contract.ruleCount} does not match ${contract.rules.length} rules`, + ); + } + + if (mobileSummary.ruleCount !== contract.ruleCount) { + throw new Error( + `Manifest Mobile ruleCount ${mobileSummary.ruleCount} does not match contract ruleCount ${contract.ruleCount}`, + ); + } + + if (mobileSummary.trackingPlanId !== contract.trackingPlan.id) { + throw new Error( + `Manifest trackingPlanId '${mobileSummary.trackingPlanId}' does not match contract trackingPlan.id '${contract.trackingPlan.id}'`, + ); + } + + if (mobileSummary.hash !== contract.hash) { + throw new Error( + `Manifest Mobile hash '${mobileSummary.hash}' does not match contract hash '${contract.hash}'`, + ); + } + + const computedSemanticHash = computeSemanticContractHash(contract); + + if (computedSemanticHash !== contract.hash) { + throw new Error( + `Contract semantic hash '${contract.hash}' does not match computed hash '${computedSemanticHash}'`, + ); + } + + if (expectedAssetSha256 !== undefined) { + if (!SHA256_PATTERN.test(expectedAssetSha256)) { + throw new Error( + `Expected asset digest '${expectedAssetSha256}' is not a SHA-256 digest`, + ); + } + + if (expectedAssetSha256 !== assetSha256) { + throw new Error( + `Contract asset digest '${assetSha256}' does not match expected digest '${expectedAssetSha256}'`, + ); + } + } + + return { + manifest, + contract, + eventRules: selectEventRules(contract, eventName, versions), + assetSha256, + }; +}; diff --git a/scripts/analytics/typed-events/generate-pilot.ts b/scripts/analytics/typed-events/generate-pilot.ts new file mode 100644 index 000000000000..aa5020f1468f --- /dev/null +++ b/scripts/analytics/typed-events/generate-pilot.ts @@ -0,0 +1,101 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { format } from 'prettier'; +import { verifyMobileContract } from './contract'; +import { generateEventPilot } from './generator'; +import { QUICK_BUY_AMOUNT_SELECTED_PILOT } from './quick-buy-pilot'; + +const readOptions = ( + args: readonly string[], +): ReadonlyMap => { + const options = new Map(); + + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + + if (!argument?.startsWith('--')) { + throw new Error(`Unexpected argument '${argument ?? ''}'`); + } + + const value = args[index + 1]; + + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for '${argument}'`); + } + + options.set(argument.slice(2), value); + index++; + } + + return options; +}; + +const readRequiredOption = ( + options: ReadonlyMap, + name: string, +): string => { + const value = options.get(name); + + if (!value) { + throw new Error(`Missing required option '--${name}'`); + } + + return value; +}; + +const main = async (): Promise => { + const options = readOptions(process.argv.slice(2)); + const outputDirectory = readRequiredOption(options, 'output-directory'); + const pilot = QUICK_BUY_AMOUNT_SELECTED_PILOT; + const verifiedContract = verifyMobileContract({ + manifestPath: readRequiredOption(options, 'manifest'), + contractPath: readRequiredOption(options, 'contract'), + expectedAssetSha256: readRequiredOption(options, 'asset-sha256'), + eventName: pilot.eventName, + versions: pilot.versions.map(({ version }) => version), + }); + const generated = generateEventPilot(verifiedContract.eventRules, pilot); + const sourcePath = join(outputDirectory, `${pilot.exportName}.ts`); + const typeTestsPath = join( + outputDirectory, + `${pilot.exportName}.test-d.ts`, + ); + + mkdirSync(outputDirectory, { recursive: true }); + writeFileSync( + sourcePath, + await format(generated.source, { + parser: 'typescript', + singleQuote: true, + filepath: sourcePath, + }), + ); + writeFileSync( + typeTestsPath, + await format(generated.typeTests, { + parser: 'typescript', + singleQuote: true, + filepath: typeTestsPath, + }), + ); + + console.log( + JSON.stringify( + { + event: pilot.eventName, + versions: pilot.versions.map(({ version }) => version), + sourcePath, + typeTestsPath, + }, + null, + 2, + ), + ); +}; + +if (process.argv[1]?.endsWith('generate-pilot.ts')) { + main().catch((error: unknown) => { + console.error(String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/analytics/typed-events/generator.test.ts b/scripts/analytics/typed-events/generator.test.ts new file mode 100644 index 000000000000..03cc90122d5d --- /dev/null +++ b/scripts/analytics/typed-events/generator.test.ts @@ -0,0 +1,168 @@ +import type { ContractRule, JsonObject } from './contract'; +import { generateEventPilot } from './generator'; +import { + QUICK_BUY_AMOUNT_SELECTED_PILOT, + type EventPilotDefinition, +} from './quick-buy-pilot'; + +const property = ( + type: 'integer' | 'number' | 'string', + enumValues?: readonly (number | string)[], +): JsonObject => ({ + type: [type], + ...(enumValues ? { enum: enumValues } : {}), +}); + +const V1_PROPERTIES: JsonObject = { + amount_selection_method: property('string', ['preset', 'custom_input']), + amount_usd: property('number'), + caip19: property('string'), + pay_with_token: property('string'), + preset_value: property('integer', [20, 50, 100, 250]), + source: property('string', ['leaderboard']), + trader_address: property('string'), +}; + +const V2_PROPERTIES: JsonObject = { + ...V1_PROPERTIES, + amount_selection_method: property('string', [ + 'preset', + 'custom_input', + 'slider', + ]), + chain_name: property('string'), + original_entry_point: property('string', ['leaderboard']), + perps_market: property('string'), + preset_value: property('integer', [10, 50, 100, 250]), + slider_percent: property('integer'), +}; + +const createRule = ( + version: number, + properties: JsonObject, +): ContractRule => ({ + key: 'Quick Buy Amount Selected', + type: 'TRACK', + version, + jsonSchema: { + type: 'object', + properties: { + properties: { + type: 'object', + properties, + required: ['amount_usd', 'amount_selection_method', 'source'], + }, + }, + }, + deprecatedAt: null, +}); + +const createRules = (): readonly ContractRule[] => [ + createRule(1, V1_PROPERTIES), + createRule(2, V2_PROPERTIES), +]; + +describe('generateEventPilot', () => { + it('generates distinct exact v1 and v2 APIs with version context', () => { + const rules = createRules(); + + const result = generateEventPilot( + rules, + QUICK_BUY_AMOUNT_SELECTED_PILOT, + ); + + expect(result.source).toContain( + 'export interface QuickBuyAmountSelectedV1Properties', + ); + expect(result.source).toContain( + 'export interface QuickBuyAmountSelectedV2Properties', + ); + expect(result.source).toContain( + 'readonly amount_selection_method: "preset" | "custom_input";', + ); + expect(result.source).toContain( + 'readonly amount_selection_method: "preset" | "custom_input" | "slider";', + ); + expect(result.source).toContain( + 'Record, never>', + ); + expect(result.source).toContain('event_version: version'); + expect(result.source).toContain( + 'v2: createEventVersion<\n QuickBuyAmountSelectedV2Properties', + ); + }); + + it('generates compile-fail checks for required, unknown, enum, and version errors', () => { + const rules = createRules(); + + const result = generateEventPilot( + rules, + QUICK_BUY_AMOUNT_SELECTED_PILOT, + ); + + expect(result.typeTests).toContain( + '@ts-expect-error source is required', + ); + expect(result.typeTests).toContain( + '@ts-expect-error unknown properties are rejected', + ); + expect(result.typeTests).toContain( + '@ts-expect-error slider is not a v1 amount selection method', + ); + expect(result.typeTests).toContain( + '@ts-expect-error only generated versions are available', + ); + }); + + it('rejects a required contract property omitted by the pilot allowlist', () => { + const rules = createRules(); + const pilot: EventPilotDefinition = { + ...QUICK_BUY_AMOUNT_SELECTED_PILOT, + versions: QUICK_BUY_AMOUNT_SELECTED_PILOT.versions.map((definition) => + definition.version === 2 + ? { + ...definition, + properties: definition.properties.filter( + (name) => name !== 'source', + ), + } + : definition, + ), + }; + + expect(() => generateEventPilot(rules, pilot)).toThrow( + 'allowlist omits required v2 properties: source', + ); + }); + + it('rejects a pilot property missing from the deployed version', () => { + const rules = [ + createRule(1, V1_PROPERTIES), + createRule(2, { + ...V2_PROPERTIES, + slider_percent: undefined, + } as unknown as JsonObject), + ]; + + expect(() => + generateEventPilot(rules, QUICK_BUY_AMOUNT_SELECTED_PILOT), + ).toThrow('Expected object at rule v2.properties.slider_percent'); + }); + + it('rejects schema constraints the narrow generator does not implement', () => { + const rules = [ + createRule(1, V1_PROPERTIES), + createRule(2, { + ...V2_PROPERTIES, + slider_percent: { + type: ['integer'], + minimum: 0, + }, + }), + ]; + + expect(() => + generateEventPilot(rules, QUICK_BUY_AMOUNT_SELECTED_PILOT), + ).toThrow('Unsupported JSON Schema keys'); + }); +}); diff --git a/scripts/analytics/typed-events/generator.ts b/scripts/analytics/typed-events/generator.ts new file mode 100644 index 000000000000..0054c734b82d --- /dev/null +++ b/scripts/analytics/typed-events/generator.ts @@ -0,0 +1,391 @@ +import type { + ContractRule, + JsonObject, + JsonValue, +} from './contract'; +import type { EventPilotDefinition } from './quick-buy-pilot'; + +interface PropertyDefinition { + readonly description?: string; + readonly name: string; + readonly required: boolean; + readonly type: string; +} + +interface VersionDefinition { + readonly properties: readonly PropertyDefinition[]; + readonly version: number; +} + +export interface GeneratedEventFiles { + readonly source: string; + readonly typeTests: string; +} + +const SUPPORTED_PROPERTY_KEYS = new Set(['description', 'enum', 'type']); + +const isJsonObject = (value: JsonValue | undefined): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const readObject = ( + value: JsonValue | undefined, + path: string, +): JsonObject => { + if (!isJsonObject(value)) { + throw new Error(`Expected object at ${path}`); + } + + return value; +}; + +const readStringArray = ( + value: JsonValue | undefined, + path: string, +): readonly string[] => { + if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) { + throw new Error(`Expected string array at ${path}`); + } + + return value; +}; + +const readPropertyType = ( + schema: JsonObject, + path: string, +): 'boolean' | 'integer' | 'number' | 'string' => { + const rawType = schema.type; + const type = + typeof rawType === 'string' + ? rawType + : Array.isArray(rawType) && rawType.length === 1 + ? rawType[0] + : undefined; + + if ( + type !== 'boolean' && + type !== 'integer' && + type !== 'number' && + type !== 'string' + ) { + throw new Error(`Unsupported JSON Schema type at ${path}.type`); + } + + return type; +}; + +const readEnumType = ( + schema: JsonObject, + primitiveType: 'boolean' | 'integer' | 'number' | 'string', + path: string, +): string | undefined => { + const enumValues = schema.enum; + + if (enumValues === undefined) { + return undefined; + } + + if (!Array.isArray(enumValues) || enumValues.length === 0) { + throw new Error(`Expected non-empty enum at ${path}.enum`); + } + + const isExpectedType = (value: JsonValue): boolean => { + switch (primitiveType) { + case 'boolean': + return typeof value === 'boolean'; + case 'integer': + return typeof value === 'number' && Number.isInteger(value); + case 'number': + return typeof value === 'number'; + case 'string': + return typeof value === 'string'; + default: + return false; + } + }; + + if (!enumValues.every(isExpectedType)) { + throw new Error(`Enum values do not match ${primitiveType} at ${path}.enum`); + } + + return enumValues.map((value) => JSON.stringify(value)).join(' | '); +}; + +const toTypeScriptType = (schema: JsonObject, path: string): string => { + const unsupportedKeys = Object.keys(schema).filter( + (key) => !SUPPORTED_PROPERTY_KEYS.has(key), + ); + + if (unsupportedKeys.length > 0) { + throw new Error( + `Unsupported JSON Schema keys at ${path}: ${unsupportedKeys.join(', ')}`, + ); + } + + const primitiveType = readPropertyType(schema, path); + const enumType = readEnumType(schema, primitiveType, path); + + if (enumType) { + return enumType; + } + + return primitiveType === 'integer' ? 'number' : primitiveType; +}; + +const readDescription = (schema: JsonObject, path: string): string | undefined => { + const description = schema.description; + + if (description === undefined) { + return undefined; + } + + if (typeof description !== 'string') { + throw new Error(`Expected string at ${path}.description`); + } + + return description.trim(); +}; + +const readRuleProperties = ( + rule: ContractRule, +): { + readonly properties: JsonObject; + readonly required: readonly string[]; +} => { + const schemaProperties = readObject( + rule.jsonSchema.properties, + `rule v${rule.version}.jsonSchema.properties`, + ); + const eventProperties = readObject( + schemaProperties.properties, + `rule v${rule.version}.jsonSchema.properties.properties`, + ); + + return { + properties: readObject( + eventProperties.properties, + `rule v${rule.version}.properties`, + ), + required: readStringArray( + eventProperties.required, + `rule v${rule.version}.required`, + ), + }; +}; + +const buildVersionDefinition = ( + rule: ContractRule, + propertyAllowlist: readonly string[], +): VersionDefinition => { + const { properties, required } = readRuleProperties(rule); + const omittedRequiredProperties = required.filter( + (property) => !propertyAllowlist.includes(property), + ); + + if (omittedRequiredProperties.length > 0) { + throw new Error( + `Pilot allowlist omits required v${rule.version} properties: ${omittedRequiredProperties.join(', ')}`, + ); + } + + const definitions = [...propertyAllowlist] + .sort() + .map((name): PropertyDefinition => { + const path = `rule v${rule.version}.properties.${name}`; + const schema = readObject(properties[name], path); + + return { + description: readDescription(schema, path), + name, + required: required.includes(name), + type: toTypeScriptType(schema, path), + }; + }); + + return { + properties: definitions, + version: rule.version, + }; +}; + +const escapeComment = (value: string): string => + value.replaceAll('*/', '*\\/').replaceAll('\n', ' '); + +const renderProperty = (property: PropertyDefinition): string => { + const comment = property.description + ? ` /** ${escapeComment(property.description)} */\n` + : ''; + const optional = property.required ? '' : '?'; + + return `${comment} readonly ${property.name}${optional}: ${property.type};`; +}; + +const renderInterface = ( + exportName: string, + definition: VersionDefinition, +): string => `export interface ${exportName}V${definition.version}Properties { +${definition.properties.map(renderProperty).join('\n')} +}`; + +const renderVersion = ( + exportName: string, + definition: VersionDefinition, +): string => ` v${definition.version}: createEventVersion< + ${exportName}V${definition.version}Properties + >(transport, EVENT_NAME, ${definition.version}),`; + +const renderValidProperties = (definition: VersionDefinition): string => + definition.properties + .filter((property) => property.required) + .map((property) => { + const firstType = property.type.split(' | ')[0]; + const value = + firstType.startsWith('"') || + /^-?\d+(?:\.\d+)?$/u.test(firstType) || + firstType === 'true' || + firstType === 'false' + ? firstType + : property.type === 'number' + ? '1' + : property.type === 'boolean' + ? 'true' + : "'value'"; + + return ` ${property.name}: ${value},`; + }) + .join('\n'); + +const renderTypeTests = ( + pilot: EventPilotDefinition, + versions: readonly VersionDefinition[], +): string => { + const [firstVersion, secondVersion] = versions; + + if (!firstVersion || !secondVersion) { + throw new Error('The pilot type test requires two event versions'); + } + + return `import { create${pilot.exportName} } from './${pilot.exportName}'; + +declare const transport: Parameters[0]; + +const event = create${pilot.exportName}(transport); + +event.v${firstVersion.version}.track({ +${renderValidProperties(firstVersion)} +}); + +event.v${secondVersion.version}.track({ +${renderValidProperties(secondVersion)} +}); + +// @ts-expect-error source is required +event.v${secondVersion.version}.track({ + amount_selection_method: 'custom_input', + amount_usd: 1, +}); + +event.v${secondVersion.version}.track({ +${renderValidProperties(secondVersion)} + // @ts-expect-error unknown properties are rejected + unknown_property: true, +}); + +event.v${firstVersion.version}.track({ +${renderValidProperties(firstVersion).replace( + /amount_selection_method: [^,]+,/u, + "// @ts-expect-error slider is not a v1 amount selection method\n amount_selection_method: 'slider',", +)} +}); + +// @ts-expect-error only generated versions are available +event.v3.track({}); +`; +}; + +/** + * Generate a transport-neutral, versioned TypeScript analytics facade. + * + * @param rules - Verified rules selected from the deployed contract. + * @param pilot - Reviewed caller-property boundary for the pilot event. + * @returns Generated implementation and compile-time conformance tests. + */ +export const generateEventPilot = ( + rules: readonly ContractRule[], + pilot: EventPilotDefinition, +): GeneratedEventFiles => { + const versions = pilot.versions.map((versionDefinition) => { + const rule = rules.find( + (candidate) => + candidate.key === pilot.eventName && + candidate.type === 'TRACK' && + candidate.version === versionDefinition.version, + ); + + if (!rule) { + throw new Error( + `Missing verified rule for '${pilot.eventName}' v${versionDefinition.version}`, + ); + } + + return buildVersionDefinition(rule, versionDefinition.properties); + }); + + const source = `/** + * GENERATED FILE — DO NOT EDIT. + * + * Generated from the verified deployed Mobile analytics contract. + */ + +export interface AnalyticsEventContext { + readonly protocols: { + readonly event_version: number; + }; +} + +export interface AnalyticsEventTransport { + track( + eventName: string, + properties: object, + context: AnalyticsEventContext, + ): void; +} + +type ExactProperties = Actual & + Record, never>; + +interface EventVersion { + track( + properties: ExactProperties, + ): void; +} + +const createEventVersion = ( + transport: AnalyticsEventTransport, + eventName: string, + version: number, +): EventVersion => ({ + track: (properties) => { + transport.track(eventName, properties as object, { + protocols: { + event_version: version, + }, + }); + }, +}); + +const EVENT_NAME = ${JSON.stringify(pilot.eventName)}; + +${versions.map((definition) => renderInterface(pilot.exportName, definition)).join('\n\n')} + +export const create${pilot.exportName} = ( + transport: AnalyticsEventTransport, +) => ({ +${versions.map((definition) => renderVersion(pilot.exportName, definition)).join('\n')} +}); +`; + + return { + source, + typeTests: renderTypeTests(pilot, versions), + }; +}; diff --git a/scripts/analytics/typed-events/quick-buy-pilot.ts b/scripts/analytics/typed-events/quick-buy-pilot.ts new file mode 100644 index 000000000000..ea6d06e90fd4 --- /dev/null +++ b/scripts/analytics/typed-events/quick-buy-pilot.ts @@ -0,0 +1,58 @@ +export interface EventVersionDefinition { + readonly version: number; + readonly properties: readonly string[]; +} + +export interface EventPilotDefinition { + readonly eventName: string; + readonly exportName: string; + readonly versions: readonly EventVersionDefinition[]; +} + +/** + * Explicit caller-owned property boundary for the first typed analytics pilot. + * + * The deployed Segment contract flattens event properties together with + * automatically collected SDK and destination properties. Until the contract + * exposes property provenance, this reviewed allowlist prevents generated + * callers from setting those global properties. + * + * This is intentionally a temporary, narrow pilot configuration rather than + * the final all-events registry. A future generic generator can derive event + * definitions from the contract, while explicit client policy can remain + * where caller ownership cannot be inferred safely. + */ +export const QUICK_BUY_AMOUNT_SELECTED_PILOT: EventPilotDefinition = { + eventName: 'Quick Buy Amount Selected', + exportName: 'QuickBuyAmountSelected', + versions: [ + { + version: 1, + properties: [ + 'amount_selection_method', + 'amount_usd', + 'caip19', + 'pay_with_token', + 'preset_value', + 'source', + 'trader_address', + ], + }, + { + version: 2, + properties: [ + 'amount_selection_method', + 'amount_usd', + 'caip19', + 'chain_name', + 'original_entry_point', + 'pay_with_token', + 'perps_market', + 'preset_value', + 'slider_percent', + 'source', + 'trader_address', + ], + }, + ], +}; diff --git a/scripts/analytics/typed-events/verify-contract.ts b/scripts/analytics/typed-events/verify-contract.ts new file mode 100644 index 000000000000..9b35128a7fd3 --- /dev/null +++ b/scripts/analytics/typed-events/verify-contract.ts @@ -0,0 +1,98 @@ +import { + verifyMobileContract, + type VerifiedMobileContract, +} from './contract'; + +const readOptions = ( + args: readonly string[], +): ReadonlyMap => { + const options = new Map(); + + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + + if (!argument?.startsWith('--')) { + throw new Error(`Unexpected argument '${argument ?? ''}'`); + } + + const value = args[index + 1]; + + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for '${argument}'`); + } + + options.set(argument.slice(2), value); + index++; + } + + return options; +}; + +const readRequiredOption = ( + options: ReadonlyMap, + name: string, +): string => { + const value = options.get(name); + + if (!value) { + throw new Error(`Missing required option '--${name}'`); + } + + return value; +}; + +const readVersions = (value: string | undefined): readonly number[] => { + if (!value) { + return [1, 2]; + } + + const versions = value.split(',').map((version) => Number(version)); + + if ( + versions.some( + (version) => !Number.isInteger(version) || version < 1, + ) + ) { + throw new Error( + `Expected comma-separated positive integer versions, got '${value}'`, + ); + } + + return versions; +}; + +const summarize = ( + result: VerifiedMobileContract, +): Record => ({ + sourceSha: result.manifest.sourceSha, + trackingPlanId: result.contract.trackingPlan.id, + ruleCount: result.contract.ruleCount, + contractHash: result.contract.hash, + assetSha256: result.assetSha256, + event: { + name: result.eventRules[0]?.key, + versions: result.eventRules.map((rule) => rule.version), + }, +}); + +const main = (): void => { + const options = readOptions(process.argv.slice(2)); + const result = verifyMobileContract({ + manifestPath: readRequiredOption(options, 'manifest'), + contractPath: readRequiredOption(options, 'contract'), + expectedAssetSha256: options.get('asset-sha256'), + eventName: options.get('event'), + versions: readVersions(options.get('versions')), + }); + + console.log(JSON.stringify(summarize(result), null, 2)); +}; + +if (process.argv[1]?.endsWith('verify-contract.ts')) { + try { + main(); + } catch (error) { + console.error(String(error)); + process.exitCode = 1; + } +} diff --git a/sonar-project.properties b/sonar-project.properties index 34b55ae13dea..d05ca20d8a9d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -9,8 +9,9 @@ sonar.sources=app/ # Binary assets are excluded so the scanner does not probe them as UTF-8 text. sonar.exclusions=**.stories.**, **/*.perf-test.tsx, e2e/**, tests/**, wdio/**, app/components/UI/Perps/utils/e2eBridgePerps.ts, **/*.png, **/*.jpg, **/*.jpeg, **/*.gif, **/*.webp, **/*.mp4, **/*.otf, **/*.ttf, **/*.woff, **/*.woff2, **/*.riv -# Inclusions for test files. -sonar.test.inclusions=**.test.** +# Inclusions for test files. Type-only `.test-d.ts` fixtures are validated by +# TypeScript and do not produce runtime coverage. +sonar.test.inclusions=**.test.**, **.test-d.** # Excluded project files from coverage. sonar.coverage.exclusions=**util/test**, **/__mocks__/**, **NetworkConnectMultiSelector.tsx, **TransactionNotification/index.js, **BrowserTab.tsx, **RPCMethodMiddleware.ts, **PermissionsSummary.tsx, **useSwitchNetworks.ts, **ethereum-chain-utils.js, **app/selectors/selectedNetworkController.ts, **useAddressBalance.ts, **AccountPermissions.tsx, **AddressFrom.tsx, **TransactionDetails**, **TransactionElement**, **networkController.ts, **util/networks/index.js, **AccountFromToInfoCard.tsx, **Engine/Engine.ts, **TransactionReview/index.js, **smart-publish-hook.ts, **AccountFromToInfoCard.types.tsx, **BrowserTab.tsx, **NetworkSelector.tsx, app/components/UI/Perps/utils/e2eBridgePerps.ts, **/MarketInsightsEntryCardOriginal.tsx, **/AnimatedGradientBorder.tsx, app/components/UI/Ramp/debug/**, app/components/UI/Perps/Debug/**