Skip to content

Commit 444a833

Browse files
Fixed1
Made-with: Cursor
1 parent ad8168a commit 444a833

32 files changed

Lines changed: 820 additions & 120 deletions

.detoxrc.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,19 @@ module.exports = {
8383
app: 'android.release',
8484
},
8585
},
86+
artifacts: {
87+
rootDir: 'artifacts',
88+
plugins: {
89+
log: { enabled: true },
90+
screenshot: {
91+
enabled: true,
92+
shouldTakeAutomaticSnapshots: false,
93+
keepOnlyFailedTestsArtifacts: false,
94+
},
95+
video: {
96+
enabled: true,
97+
keepOnlyFailedTestsArtifacts: true,
98+
},
99+
},
100+
},
86101
};

App.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { View } from 'react-native';
23
import { StatusBar } from 'expo-status-bar';
34
import { AppNavigator } from './src/navigation/AppNavigator';
45
import { useNotifications } from './src/hooks/useNotifications';
@@ -12,6 +13,7 @@ import { createAppKit, defaultConfig, AppKit } from '@reown/appkit-ethers-react-
1213

1314
import { EVM_RPC_URLS } from './src/config/evm';
1415
import { useNetworkStore } from './src/store';
16+
import { sessionService } from './src/services/auth/session';
1517

1618
// Get projectId from environment variable
1719
const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID';
@@ -72,20 +74,21 @@ function NotificationBootstrap() {
7274
const { initialize } = useNetworkStore();
7375
React.useEffect(() => {
7476
initialize();
77+
void sessionService.initializeCurrentSession();
7578
}, [initialize]);
7679

7780
return null;
7881
}
7982

8083
export default function App() {
8184
return (
82-
<>
85+
<View style={{ flex: 1 }} testID="app-root">
8386
<StatusBar style="light" />
8487
<ErrorBoundary>
8588
<NotificationBootstrap />
8689
<AppNavigator />
8790
</ErrorBoundary>
8891
<AppKit />
89-
</>
92+
</View>
9093
);
9194
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Placeholder Certora-style rule file for CI integration.
2+
// The exact contract bindings should be updated when Certora harness generation is added.
3+
4+
methods {
5+
// Core state transitions
6+
subscribe(env, proxy, storage, subscriber, plan_id) returns uint64 envfree;
7+
cancel_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
8+
pause_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
9+
resume_subscription(env, proxy, storage, subscriber, subscription_id) envfree;
10+
charge_subscription(env, proxy, storage, subscription_id) envfree;
11+
}
12+
13+
rule noCancelledToActive(uint64 subscription_id) {
14+
// Placeholder rule: implementation should assert cancelled subscriptions
15+
// cannot return to Active status after cancellation.
16+
true;
17+
}
18+
19+
rule subscriptionCountMonotonic() {
20+
// Placeholder invariant: subscription count never decreases.
21+
true;
22+
}
23+
24+
rule refundBoundedByTotalPaid(uint64 subscription_id) {
25+
// Placeholder invariant: refund request <= total paid.
26+
true;
27+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"msg": "SubTrackr subscription formal verification",
3+
"verify": "SubTrackrSubscription:SubTrackrSubscription.spec",
4+
"rule_sanity": "basic",
5+
"optimistic_loop": true
6+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# SubTrackr Subscription Formal Specification
2+
3+
## Scope
4+
5+
This spec covers core safety properties for:
6+
7+
- `subscribe`
8+
- `charge_subscription`
9+
- `cancel_subscription`
10+
- `pause_subscription` / `resume_subscription`
11+
- `request_transfer` / `accept_transfer`
12+
13+
## Authorization Rules
14+
15+
1. Only authorized actor(s) can mutate subscription ownership or state.
16+
2. Non-admin callers cannot bypass `require_auth`.
17+
3. Refund approval/rejection can only be executed by admin.
18+
19+
## Balance Rules
20+
21+
1. `charge_subscription` transfers exactly `plan.price` from subscriber to merchant.
22+
2. `total_paid` is monotonically non-decreasing except when explicit refunds are approved.
23+
3. `refund_requested_amount` never exceeds `total_paid`.
24+
25+
## State Transition Rules
26+
27+
Allowed transitions:
28+
29+
- `Active -> Paused`
30+
- `Paused -> Active`
31+
- `Active|Paused -> Cancelled`
32+
33+
Disallowed transitions:
34+
35+
- `Cancelled -> Active`
36+
- Any transition by unauthorized actors
37+
38+
## Invariants
39+
40+
1. `SubscriptionCount` is monotonically non-decreasing.
41+
2. `Plan.subscriber_count >= 0` (underflow impossible).
42+
3. `next_charge_at >= last_charged_at` for non-cancelled subscriptions.
43+
4. A user has at most one active/non-cancelled subscription per plan (`UserPlanIndex` uniqueness).
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Formal Verification Results
2+
3+
This document records the latest formal verification status for `contracts/subscription`.
4+
5+
## Properties Under Verification
6+
7+
- Authorization invariants
8+
- Balance and refund safety bounds
9+
- Subscription state transition correctness
10+
- Global invariants (count monotonicity, index uniqueness)
11+
12+
## Latest Run
13+
14+
- Status: `Pending initial baseline run`
15+
- CI Workflow: `.github/workflows/formal-verification.yml`
16+
- Tooling: `certora-cli`
17+
18+
## Notes
19+
20+
- The current spec in `contracts/subscription/certora/SubTrackrSubscription.spec` is scaffolded.
21+
- Replace placeholder rules with concrete storage-model assertions as the harness evolves.

e2e/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# SubTrackr E2E Suite
2+
3+
## Coverage
4+
5+
- Subscription creation flow
6+
- Subscription charging simulation flow
7+
- Subscription cancellation flow
8+
- Subscription plan change flow
9+
- Visual regression snapshots (home + detail screens)
10+
11+
## Parallel execution
12+
13+
- iOS: `npm run e2e:test-ios:parallel`
14+
- Android: `npm run e2e:test-android:parallel`
15+
16+
## Visual baselines
17+
18+
Visual hashes are stored in `e2e/fixtures/visual-baselines.json`.
19+
20+
- Run in strict comparison mode (default): screenshots are compared to stored hashes.
21+
- Update baselines intentionally:
22+
23+
```bash
24+
UPDATE_VISUAL_BASELINE=true npm run e2e:test-ios -- --testNamePattern "Subscription Visual Regression"
25+
```

e2e/fixtures/visual-baselines.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

e2e/helpers/subscriptionFlows.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { by, device, element, expect, waitFor } from 'detox';
2+
3+
const BILLING_LABELS: Record<'monthly' | 'yearly' | 'weekly', string> = {
4+
monthly: 'Monthly',
5+
yearly: 'Yearly',
6+
weekly: 'Weekly',
7+
};
8+
9+
export const launchCleanApp = async () => {
10+
await device.launchApp({ newInstance: true, delete: true });
11+
await waitFor(element(by.id('app-root'))).toExist().withTimeout(30000);
12+
await waitFor(element(by.id('home-screen'))).toExist().withTimeout(30000);
13+
};
14+
15+
export const createSubscription = async (
16+
name: string,
17+
price: string,
18+
cycle: 'monthly' | 'yearly' | 'weekly' = 'monthly'
19+
) => {
20+
await element(by.id('add-subscription-button')).tap();
21+
await waitFor(element(by.id('add-subscription-screen'))).toBeVisible().withTimeout(10000);
22+
await expect(element(by.id('subscription-form-title'))).toBeVisible();
23+
24+
await element(by.id('subscription-name-input')).replaceText(name);
25+
await element(by.id('subscription-price-input')).replaceText(price);
26+
27+
if (cycle !== 'monthly') {
28+
await element(by.id(`billing-cycle-option-${cycle}`)).tap();
29+
}
30+
31+
await element(by.id('save-subscription-button')).tap();
32+
await dismissAnySystemAlert();
33+
34+
await waitFor(element(by.text(name))).toBeVisible().withTimeout(15000);
35+
};
36+
37+
export const openSubscriptionByName = async (name: string) => {
38+
await waitFor(element(by.text(name))).toBeVisible().withTimeout(10000);
39+
await element(by.text(name)).tap();
40+
await waitFor(element(by.id('subscription-detail-screen'))).toBeVisible().withTimeout(10000);
41+
};
42+
43+
export const expectBillingCycle = async (cycle: 'monthly' | 'yearly' | 'weekly') => {
44+
await expect(element(by.id('subscription-billing-cycle-value'))).toHaveText(BILLING_LABELS[cycle]);
45+
};
46+
47+
export const dismissAnySystemAlert = async () => {
48+
const labels = ['OK', 'Ok', 'Later', 'Cancel'];
49+
for (const label of labels) {
50+
const alertButton = element(by.text(label));
51+
try {
52+
await waitFor(alertButton).toBeVisible().withTimeout(600);
53+
await alertButton.tap();
54+
return;
55+
} catch {
56+
// No-op: button not present.
57+
}
58+
}
59+
};

e2e/helpers/visualRegression.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as crypto from 'crypto';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
5+
type BaselineMap = Record<string, string>;
6+
7+
const baselineFile = path.resolve(__dirname, '../fixtures/visual-baselines.json');
8+
9+
const readBaselines = (): BaselineMap => {
10+
if (!fs.existsSync(baselineFile)) return {};
11+
return JSON.parse(fs.readFileSync(baselineFile, 'utf8')) as BaselineMap;
12+
};
13+
14+
const writeBaselines = (baselines: BaselineMap) => {
15+
fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
16+
fs.writeFileSync(baselineFile, JSON.stringify(baselines, null, 2));
17+
};
18+
19+
const hashFile = (filePath: string) => {
20+
const content = fs.readFileSync(filePath);
21+
return crypto.createHash('sha256').update(content).digest('hex');
22+
};
23+
24+
export const assertVisualSnapshot = (name: string, screenshotPath: string) => {
25+
const baselines = readBaselines();
26+
const currentHash = hashFile(screenshotPath);
27+
const updateBaselines = process.env.UPDATE_VISUAL_BASELINE === 'true';
28+
29+
if (!baselines[name] || updateBaselines) {
30+
baselines[name] = currentHash;
31+
writeBaselines(baselines);
32+
return;
33+
}
34+
35+
expect(currentHash).toBe(baselines[name]);
36+
};

0 commit comments

Comments
 (0)