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
15 changes: 15 additions & 0 deletions .detoxrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,19 @@ module.exports = {
app: 'android.release',
},
},
artifacts: {
rootDir: 'artifacts',
plugins: {
log: { enabled: true },
screenshot: {
enabled: true,
shouldTakeAutomaticSnapshots: false,
keepOnlyFailedTestsArtifacts: false,
},
video: {
enabled: true,
keepOnlyFailedTestsArtifacts: true,
},
},
},
};
7 changes: 5 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { AppNavigator } from './src/navigation/AppNavigator';
import { useNotifications } from './src/hooks/useNotifications';
Expand All @@ -12,6 +13,7 @@ import { createAppKit, defaultConfig, AppKit } from '@reown/appkit-ethers-react-

import { EVM_RPC_URLS } from './src/config/evm';
import { useNetworkStore } from './src/store';
import { sessionService } from './src/services/auth/session';

// Get projectId from environment variable
const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID';
Expand Down Expand Up @@ -72,20 +74,21 @@ function NotificationBootstrap() {
const { initialize } = useNetworkStore();
React.useEffect(() => {
initialize();
void sessionService.initializeCurrentSession();
}, [initialize]);

return null;
}

export default function App() {
return (
<>
<View style={{ flex: 1 }} testID="app-root">
<StatusBar style="light" />
<ErrorBoundary>
<NotificationBootstrap />
<AppNavigator />
</ErrorBoundary>
<AppKit />
</>
</View>
);
}
27 changes: 27 additions & 0 deletions contracts/subscription/certora/SubTrackrSubscription.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Placeholder Certora-style rule file for CI integration.
// The exact contract bindings should be updated when Certora harness generation is added.

methods {
// Core state transitions
subscribe(env, proxy, storage, subscriber, plan_id) returns uint64 envfree;
cancel_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
pause_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
resume_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
charge_subscription(env, proxy, storage, subscription_id) envfree;
}

rule noCancelledToActive(uint64 subscription_id) {
// Placeholder rule: implementation should assert cancelled subscriptions
// cannot return to Active status after cancellation.
true;
}

rule subscriptionCountMonotonic() {
// Placeholder invariant: subscription count never decreases.
true;
}

rule refundBoundedByTotalPaid(uint64 subscription_id) {
// Placeholder invariant: refund request <= total paid.
true;
}
6 changes: 6 additions & 0 deletions contracts/subscription/certora/certora.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"msg": "SubTrackr subscription formal verification",
"verify": "SubTrackrSubscription:SubTrackrSubscription.spec",
"rule_sanity": "basic",
"optimistic_loop": true
}
43 changes: 43 additions & 0 deletions contracts/subscription/specs/core-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# SubTrackr Subscription Formal Specification

## Scope

This spec covers core safety properties for:

- `subscribe`
- `charge_subscription`
- `cancel_subscription`
- `pause_subscription` / `resume_subscription`
- `request_transfer` / `accept_transfer`

## Authorization Rules

1. Only authorized actor(s) can mutate subscription ownership or state.
2. Non-admin callers cannot bypass `require_auth`.
3. Refund approval/rejection can only be executed by admin.

## Balance Rules

1. `charge_subscription` transfers exactly `plan.price` from subscriber to merchant.
2. `total_paid` is monotonically non-decreasing except when explicit refunds are approved.
3. `refund_requested_amount` never exceeds `total_paid`.

## State Transition Rules

Allowed transitions:

- `Active -> Paused`
- `Paused -> Active`
- `Active|Paused -> Cancelled`

Disallowed transitions:

- `Cancelled -> Active`
- Any transition by unauthorized actors

## Invariants

1. `SubscriptionCount` is monotonically non-decreasing.
2. `Plan.subscriber_count >= 0` (underflow impossible).
3. `next_charge_at >= last_charged_at` for non-cancelled subscriptions.
4. A user has at most one active/non-cancelled subscription per plan (`UserPlanIndex` uniqueness).
21 changes: 21 additions & 0 deletions contracts/subscription/specs/verification-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Formal Verification Results

This document records the latest formal verification status for `contracts/subscription`.

## Properties Under Verification

- Authorization invariants
- Balance and refund safety bounds
- Subscription state transition correctness
- Global invariants (count monotonicity, index uniqueness)

## Latest Run

- Status: `Pending initial baseline run`
- CI Workflow: `.github/workflows/formal-verification.yml`
- Tooling: `certora-cli`

## Notes

- The current spec in `contracts/subscription/certora/SubTrackrSubscription.spec` is scaffolded.
- Replace placeholder rules with concrete storage-model assertions as the harness evolves.
25 changes: 25 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SubTrackr E2E Suite

## Coverage

- Subscription creation flow
- Subscription charging simulation flow
- Subscription cancellation flow
- Subscription plan change flow
- Visual regression snapshots (home + detail screens)

## Parallel execution

- iOS: `npm run e2e:test-ios:parallel`
- Android: `npm run e2e:test-android:parallel`

## Visual baselines

Visual hashes are stored in `e2e/fixtures/visual-baselines.json`.

- Run in strict comparison mode (default): screenshots are compared to stored hashes.
- Update baselines intentionally:

```bash
UPDATE_VISUAL_BASELINE=true npm run e2e:test-ios -- --testNamePattern "Subscription Visual Regression"
```
1 change: 1 addition & 0 deletions e2e/fixtures/visual-baselines.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
59 changes: 59 additions & 0 deletions e2e/helpers/subscriptionFlows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { by, device, element, expect, waitFor } from 'detox';

const BILLING_LABELS: Record<'monthly' | 'yearly' | 'weekly', string> = {
monthly: 'Monthly',
yearly: 'Yearly',
weekly: 'Weekly',
};

export const launchCleanApp = async () => {
await device.launchApp({ newInstance: true, delete: true });
await waitFor(element(by.id('app-root'))).toExist().withTimeout(30000);
await waitFor(element(by.id('home-screen'))).toExist().withTimeout(30000);
};

export const createSubscription = async (
name: string,
price: string,
cycle: 'monthly' | 'yearly' | 'weekly' = 'monthly'
) => {
await element(by.id('add-subscription-button')).tap();
await waitFor(element(by.id('add-subscription-screen'))).toBeVisible().withTimeout(10000);
await expect(element(by.id('subscription-form-title'))).toBeVisible();

await element(by.id('subscription-name-input')).replaceText(name);
await element(by.id('subscription-price-input')).replaceText(price);

if (cycle !== 'monthly') {
await element(by.id(`billing-cycle-option-${cycle}`)).tap();
}

await element(by.id('save-subscription-button')).tap();
await dismissAnySystemAlert();

await waitFor(element(by.text(name))).toBeVisible().withTimeout(15000);
};

export const openSubscriptionByName = async (name: string) => {
await waitFor(element(by.text(name))).toBeVisible().withTimeout(10000);
await element(by.text(name)).tap();
await waitFor(element(by.id('subscription-detail-screen'))).toBeVisible().withTimeout(10000);
};

export const expectBillingCycle = async (cycle: 'monthly' | 'yearly' | 'weekly') => {
await expect(element(by.id('subscription-billing-cycle-value'))).toHaveText(BILLING_LABELS[cycle]);
};

export const dismissAnySystemAlert = async () => {
const labels = ['OK', 'Ok', 'Later', 'Cancel'];
for (const label of labels) {
const alertButton = element(by.text(label));
try {
await waitFor(alertButton).toBeVisible().withTimeout(600);
await alertButton.tap();
return;
} catch {
// No-op: button not present.
}
}
};
36 changes: 36 additions & 0 deletions e2e/helpers/visualRegression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';

type BaselineMap = Record<string, string>;

const baselineFile = path.resolve(__dirname, '../fixtures/visual-baselines.json');

const readBaselines = (): BaselineMap => {
if (!fs.existsSync(baselineFile)) return {};
return JSON.parse(fs.readFileSync(baselineFile, 'utf8')) as BaselineMap;
};

const writeBaselines = (baselines: BaselineMap) => {
fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
fs.writeFileSync(baselineFile, JSON.stringify(baselines, null, 2));
};

const hashFile = (filePath: string) => {
const content = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
};

export const assertVisualSnapshot = (name: string, screenshotPath: string) => {
const baselines = readBaselines();
const currentHash = hashFile(screenshotPath);
const updateBaselines = process.env.UPDATE_VISUAL_BASELINE === 'true';

if (!baselines[name] || updateBaselines) {
baselines[name] = currentHash;
writeBaselines(baselines);
return;
}

expect(currentHash).toBe(baselines[name]);
};
3 changes: 2 additions & 1 deletion e2e/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.ts'],
testTimeout: 120000,
maxWorkers: 1,
maxWorkers: process.env.E2E_MAX_WORKERS ? Number(process.env.E2E_MAX_WORKERS) : 2,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
setupFilesAfterEnv: ['<rootDir>/e2e/setup.ts'],
verbose: true,
transform: {
'^.+\\.tsx?$': [
Expand Down
22 changes: 6 additions & 16 deletions e2e/launch.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import { by, device, element, expect, waitFor } from 'detox';
import { by, expect, element } from 'detox';
import { launchCleanApp } from './helpers/subscriptionFlows';

describe('App Launch', () => {
beforeAll(async () => {
await device.launchApp();
});

beforeEach(async () => {
await device.reloadReactNative();
await launchCleanApp();
});

it('should launch the app properly', async () => {
// Using robust wait to ensure app loads
const appContainer = element(by.id('app-root')).atIndex(0);
// If 'app-root' testID isn't set, we might expect another known element,
// adjusting based on what's available or failing gracefully for now.
try {
await waitFor(appContainer).toExist().withTimeout(10000);
} catch (e) {
// Fallback check if testIDs aren't fully injected yet
await expect(element(by.text('SubTrackr')).atIndex(0)).toBeVisible();
}
await expect(element(by.id('app-root'))).toExist();
await expect(element(by.id('home-screen'))).toBeVisible();
await expect(element(by.text('SubTrackr'))).toBeVisible();
});
});
40 changes: 21 additions & 19 deletions e2e/payment.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import { by, device, element, expect, waitFor } from 'detox';
import { by, element, expect, waitFor } from 'detox';
import {
createSubscription,
launchCleanApp,
openSubscriptionByName,
} from './helpers/subscriptionFlows';

describe('Crypto Payment Flow', () => {
describe('Subscription Charging Flow E2E', () => {
beforeAll(async () => {
await device.launchApp();
await launchCleanApp();
});

beforeEach(async () => {
await device.reloadReactNative();
await launchCleanApp();
});

it('should handle crypto payment modal trigger', async () => {
const subItem = element(by.text('Detox Test Sub'));
try {
await waitFor(subItem).toBeVisible().withTimeout(5000);
await subItem.tap();
it('simulates successful and failed billing events', async () => {
const subName = 'E2E Charge Flow';
await createSubscription(subName, '11.99');
await openSubscriptionByName(subName);

const payBtn = element(by.id('pay-crypto-button'));
await waitFor(payBtn).toBeVisible().withTimeout(3000);
await payBtn.tap();
await expect(element(by.id('simulate-charge-success-button'))).toBeVisible();
await element(by.id('simulate-charge-success-button')).tap();

const walletModal = element(by.id('wallet-connect-modal'));
await expect(walletModal).toBeVisible();
} catch (e) {
console.warn(
'Elements not found, test will require proper testID assignment in UI components.'
);
}
await waitFor(element(by.id('simulate-charge-failed-button'))).toBeVisible().withTimeout(5000);
await element(by.id('simulate-charge-failed-button')).tap();

// Validate action controls still available after charging operations.
await expect(element(by.id('cancel-subscription-button'))).toBeVisible();
await expect(element(by.id('pause-resume-subscription-button'))).toBeVisible();
});
});
1 change: 1 addition & 0 deletions e2e/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
jest.setTimeout(180000);
Loading
Loading