Merge pull request #1873 from selfxyz/release/staging-2026-03-27

Release to Staging v2.9.16 - 2026-03-27
This commit is contained in:
Justin Hernandez
2026-03-27 20:08:25 -07:00
committed by GitHub
242 changed files with 12241 additions and 2056 deletions

View File

@@ -0,0 +1,94 @@
---
name: gaps-to-issues
description: Create Linear issues from a PR audit doc — one issue per PR bucket with acceptance criteria and linked audit findings.
disable-model-invocation: false
user-invocable: true
argument-hint: '[path-to-audit-doc]'
---
# Gaps to Issues
You take a PR audit document (produced by `/pr-audit`) and create Linear issues from its PR buckets.
This skill **stops at issue creation**. Use `/spec-from-audit` to generate specs for the created issues.
## Input
`$ARGUMENTS` — Path to the audit document (e.g., `docs/reviews/2026-03-23-branch-audit.md`). If not provided, look for the most recently modified file in `docs/reviews/`.
## Workflow
### Step 1: Read the Audit Document
Read the audit doc. Verify it has:
- A "PR Buckets" section with at least one bucket
- Each bucket has: name, estimated LOC, findings, files, acceptance criteria
If the audit doc is missing buckets or acceptance criteria, tell the user and stop.
### Step 2: Ask for Linear Context
Ask the user:
1. Which **Linear team** to create issues under (suggest the team from recent issues if detectable)
2. Which **Linear project** to add issues to (optional)
3. Whether to set **priority** per bucket or use a default
4. Whether to **consolidate** any buckets before creating issues
Wait for confirmation before proceeding.
### Step 3: Create Issues
For each bucket, create a Linear issue using `mcp__linear-server__save_issue`:
- **Title:** Bucket name (concise, under 70 characters)
- **Team:** From Step 2
- **Project:** From Step 2 (if provided)
- **Priority:** Based on the highest severity finding in the bucket:
- Contains Critical findings → Urgent (1)
- Contains High findings → High (2)
- Contains Medium findings → Medium (3)
- Contains only Low findings → Low (4)
- **Description:** Keep it lightweight — the spec (created later by `/spec-from-audit`) is the source of truth. The issue body should contain:
- One-sentence goal
- List of finding IDs from the audit (e.g., "Covers findings #1, #3, #7")
- Dependencies on other issues if any
- A note: "See attached spec document for full implementation plan."
- Do NOT duplicate scope, file lists, or acceptance criteria — that belongs in the spec
- **Links:** Add the PR URL if one exists
If the user asked to consolidate buckets, merge the relevant findings.
### Step 4: Update the Audit Document
Add a "Follow-up Issues" section to the top of the audit doc (below the header metadata) with a table of all created issues:
```markdown
## Follow-up Issues
| Issue | Priority | Title |
|-------|----------|-------|
| [SELF-NNNN](url) | Urgent | Bucket name |
| [SELF-NNNN](url) | High | Bucket name |
```
Update the audit doc status to: `Audit complete — issues created`
### Step 5: Present Results
Show the user:
1. Table of created issues with IDs, priorities, and URLs
2. Any buckets that were skipped or consolidated
3. Remind them: "Use `/spec-from-audit` to generate specs for these issues."
**Stop here.** Do not create specs.
## Important Notes
- Never run `git commit` or `git push`.
- Ask before creating — never create issues without user confirmation on team/project/priority.
- If a bucket seems too large (>2k LOC estimate), suggest splitting before creating.
- If buckets have dependencies between them, note this in the issue descriptions and use Linear's `blockedBy` field.
- **Issue descriptions are set once at creation.** After creating an issue, never use `save_issue` to overwrite its description. All follow-up context, corrections, or status updates go in **comments** via `save_comment`. Use `save_issue` only to change structured fields (status, priority, assignee, labels).

View File

@@ -0,0 +1,210 @@
---
name: pr-audit
description: Multi-agent PR review — component, integration, and routing analysis merged into a structured audit doc with severity and PR buckets.
disable-model-invocation: false
user-invocable: true
argument-hint: '[base-branch (default: dev)]'
---
# PR Audit
You run a multi-pass review of the current branch, pull in external PR feedback, and produce a structured audit document with findings grouped into PR-sized fix buckets.
This skill **stops at the audit doc**. It does not create issues or specs — use `/gaps-to-issues` and `/spec-from-audit` for those steps.
## Input
`$ARGUMENTS` — Optional base branch name (default: `dev`).
## Workflow
### Step 1: Gather the Diff
Use `$ARGUMENTS` if provided, otherwise default to `dev`.
Verify the base branch exists:
```bash
git rev-parse --verify <base-branch>
```
If it does not exist, ask the user which branch to diff against.
Gather the full diff and context:
```bash
git log <base-branch>..HEAD --oneline --no-decorate
git diff <base-branch>...HEAD --stat
git diff <base-branch>...HEAD
git diff <base-branch>...HEAD --name-only
git status -s
```
Read the full diff carefully. You will reference it throughout the review passes.
### Step 2: Multi-Agent Review (3 Parallel Passes)
Run all three review passes **in parallel** using the Agent tool. Each agent receives the diff context and reviews through a specific lens.
#### Pass A: Component-Level Review
For every new or changed screen/component file, check:
- Missing or incorrect imports (especially cross-package imports that don't resolve)
- Wrong or missing props vs the component's actual interface — read the component definition, don't guess
- Missing Lottie/animation assets or props that the component supports but aren't passed
- Placeholder logic left in production code (hardcoded values, no-op callbacks, `TODO`/`FIXME`)
- Missing error/loading/empty states
- Raw hex colors instead of design tokens
- Files exceeding 800 LOC
#### Pass B: Integration-Level Review
For the full set of changed files, check:
- Provider/contract flow violations — does data flow match what specs define?
- State propagation between screens — is state passed correctly through navigation, or lost on refresh/direct-entry?
- Persistence gaps — state that should survive reload but only lives in `useState`
- Event ordering issues — race conditions, guards that swallow later events
- Breaking changes to shared interfaces consumed by other packages
- Build-time vs runtime config that should be dynamic
#### Pass C: Route Wiring Review
For all navigation-related changes, check:
- Every `navigate()` / `push()` / `replace()` target has a corresponding route definition
- Every new route definition is reachable from at least one navigation call
- Orphaned routes — defined but unreachable
- Screens that receive `location.state` but have no guard for direct-entry (missing state)
- Deep link configuration consistency (if applicable)
### Step 3: Pull in PR Feedback
Check if a PR exists for this branch:
```bash
gh pr view --json number,url 2>/dev/null
```
If a PR exists, fetch external review comments:
```bash
gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate
gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate
gh api repos/{owner}/{repo}/issues/{number}/comments --paginate
```
Extract actionable items from CodeRabbit, Codex, and human reviewers. Ignore resolved conversations and pure acknowledgment comments.
If no PR exists, skip this step.
### Step 4: Merge and Deduplicate
Combine findings from all three passes and PR feedback. Deduplicate — if Pass A and a CodeRabbit comment flag the same issue, keep one entry and note both sources.
Assign severity:
| Severity | Criteria |
|----------|----------|
| **Critical** | Build-breaking, crashes, data loss, security holes, blocked user flows |
| **High** | Incorrect behavior visible to users, contract violations, missing error handling on critical paths |
| **Medium** | Missing states, prop mismatches, design token violations, placeholder logic |
| **Low** | Style, minor code quality, non-blocking TODOs |
### Step 5: Group into PR-Sized Buckets
Group findings into fix buckets. Each bucket:
- Targets ≤2,000 LOC changed
- Groups related findings touching the same files or logical area
- Is independently mergeable (note dependencies if unavoidable)
- Has a clear name, estimated LOC, list of files, and acceptance criteria
### Step 6: Write the Audit Document
Determine the branch slug from the current branch name (strip `feat/`, `fix/`, etc., replace `/` with `-`).
Create the audit document at:
```
docs/reviews/YYYY-MM-DD-<branch-slug>-audit.md
```
Create `docs/reviews/` if it does not exist.
Structure:
```markdown
# PR Audit — <branch-name>
> Date: YYYY-MM-DD
> Branch: `<branch-name>`
> Base: `<base-branch>`
> PR: #NNN (if exists)
> Reviewers: Claude Code (component, integration, routing passes), [external reviewers]
> Status: Audit complete — ready for review
## Summary
[2-3 sentences describing what the branch does and the overall health assessment.]
## Findings
### Critical
- [ ] [finding] — `path/to/file.ts:NN`
- **Source:** [Pass A | Pass B | Pass C | CodeRabbit | reviewer]
- **Fix:** [concrete action]
### High
- [ ] ...
### Medium
- [ ] ...
### Low
- [ ] ...
## PR Buckets
### Bucket 1: [name]
- **Estimated LOC:** ~NNN
- **Findings:** #1, #3, #7
- **Files:** [list]
- **Dependencies:** [none | Bucket N must land first]
- **Acceptance criteria:**
- [ ] [criterion]
### Bucket 2: [name]
...
## What Works Well
[List things the branch does correctly — not just problems.]
```
### Step 7: Present to User
Show the user:
1. Total finding count by severity
2. Each bucket with name, estimated LOC, and findings covered
3. Any findings that didn't fit cleanly into a bucket
Tell the user:
> "Audit complete. Review the findings and buckets above. When ready, use `/gaps-to-issues` to create Linear issues from these buckets, then `/spec-from-audit` to generate specs."
**Stop here.** Do not create issues or specs.
## Important Notes
- Never run `git commit` or `git push`. Stage the audit doc but stop there.
- Read the full diff — do not summarize from file names alone.
- Findings must be specific and actionable with file paths and line numbers.
- When estimating LOC for buckets, be conservative.
- If the diff is very large (>10k LOC), warn the user and suggest focusing on critical/high only.

View File

@@ -0,0 +1,224 @@
---
name: pr-summary
description: Generate a GitHub PR title and summary from all branch changes, including Linear issue and Figma links when available.
disable-model-invocation: false
user-invocable: true
argument-hint: '[base-branch (default: main)]'
---
# Generate PR Title and Summary
You generate a concise GitHub PR title and a structured summary covering ALL changes on the current branch compared to the base branch.
## Input
`$ARGUMENTS` — Optional base branch name (default: `main`).
## Workflow
### Step 1: Determine the Base Branch
Use `$ARGUMENTS` if provided, otherwise default to `main`.
Verify the base branch exists:
```bash
git rev-parse --verify <base-branch>
```
If it doesn't exist, ask the user which branch to diff against.
### Step 2: Gather All Branch Changes
Run these commands to understand the FULL scope of changes on this branch (not just the latest commit):
1. **Commit log** — all commits since diverging from the base branch:
```bash
git log <base-branch>..HEAD --oneline --no-decorate
```
2. **Full diff stat** — files changed summary:
```bash
git diff <base-branch>...HEAD --stat
```
3. **Full diff** — the actual changes (read this carefully, it is the source of truth for the summary):
```bash
git diff <base-branch>...HEAD
```
4. **Unstaged/untracked changes** — anything not yet committed:
```bash
git status -s
```
If there are uncommitted changes, note them separately in the output so the user knows they won't be in the PR.
### Step 3: Scan for Linear Issue References
Search for Linear issue references in:
1. **Commit messages** — look for patterns like `SELF-XXXX`, `[SELF-XXXX]`, or Linear URLs
2. **Branch name** — the current branch name may contain an issue ID or slug
3. **Changed file content** — scan diff for Linear issue URLs or IDs in comments/docs
4. **Spec docs** — scan `specs/` for markdown files that reference the files changed on this branch. Cross-reference the changed file paths from `git diff --name-only` against the spec content to find the relevant issues.
For each Linear issue found, fetch its details:
- Call `mcp__linear-server__get_issue` with the issue ID
- Extract: title, status, URL, parent issue (if any)
If no Linear issues are found, skip this section in the output.
### Step 4: Scan for Figma References
Search for Figma resource links in:
1. **Changed file content** — scan the diff for `figma.com` URLs
2. **Linear issues found in Step 3** — check their descriptions for Figma links
3. **Commit messages** — look for Figma URLs
Collect unique Figma URLs with brief context (which screen/component they relate to).
If no Figma links are found, skip this section in the output.
### Step 5: Categorize the Changes
Group changes into categories based on what was modified:
- **React Native app** — changes under `app/`
- **SDK core** — changes under `packages/mobile-sdk-alpha/`
- **WebView app** — changes under `packages/webview-app/`
- **WebView bridge** — changes under `packages/webview-bridge/`
- **KMP SDK** — changes under `packages/kmp-sdk/`
- **Native shells** — changes under `packages/native-shell-android/` or `packages/native-shell-ios/`
- **RN SDK** — changes under `packages/rn-sdk/`
- **Noir circuits** — changes under `noir/` or `circuits/`
- **Contracts** — changes under `contracts/`
- **Common/shared** — changes under `common/` or `new-common/`
- **Tests** — test files added or modified (`.test.ts`, `.spec.ts`, etc.)
- **Assets** — images, Lottie JSON, sounds added or renamed
- **Docs/specs** — changes to `docs/`, `specs/`, `CLAUDE.md`, or other markdown
- **Config/infra** — `package.json`, `tsconfig`, CI, scripts, patches, etc.
Only include categories that have changes. Collapse small categories (1-2 files) together if it aids readability.
### Step 6: Generate the PR Title
Rules:
- Under 70 characters
- Lead with the action verb: `add`, `update`, `fix`, `remove`, `refactor`, `rename`
- Describe the overall intent, not individual files
- Do not include issue IDs in the title
### Step 7: Generate the PR Summary
Output the complete PR body using this format:
```markdown
## Summary
[2-4 bullet points covering the most important changes. Focus on the "what" and "why", not file-by-file details.]
## Changes
### [Category Name]
- [change description]
- [change description]
### [Category Name]
- [change description]
## Linear Issues
- Closes [SELF-XXXX](url) — [title]
## Figma
- [brief context](figma-url)
## Test Plan
- [ ] `yarn lint && yarn types` passes
- [ ] [package-specific validation commands as relevant]
- [ ] [any manual verification steps specific to these changes]
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
Omit the "Linear Issues" section if none were found.
Omit the "Figma" section if no Figma links were found.
If the diff touches native paths (`app/`, `packages/native-shell-*`, `packages/kmp-sdk/`, iOS/Android project files), append this checklist to the Test Plan section:
```markdown
### Native Consolidation Checklist
- [ ] CONTRACTS.md reviewed - no unintended contract changes
- [ ] Layer 1 bridge contract tests pass (`cd app && yarn jest:run` / `yarn workspace @selfxyz/rn-sdk-test-app test`)
- [ ] Layer 3 builds pass (app iOS, RN test app iOS, RN test app Android)
- [ ] Layer 4 manual smoke test signed off (if consolidation PR)
- [ ] No new native business logic added (logic belongs in TypeScript)
```
### Step 8: Check for Existing PR
Check if a PR already exists for the current branch:
```bash
gh pr view --json number,title,url 2>/dev/null
```
**If a PR exists:**
First, fetch the current PR title and body:
```bash
gh pr view --json number,title,body,url
```
If the PR body is empty/whitespace, the title matches the branch name, or the title is a generic default (slug-style text with slashes/hyphens, auto-generated patterns), note that it appears auto-generated.
Show the generated title and body, then ask: "PR #NNN appears to have a template/auto-generated description. Update it?"
If the PR has a meaningful hand-written title and body, show both the current and newly generated versions, then ask: "PR #NNN already has a custom title/description. Replace it?"
If the user confirms, run:
```bash
gh pr edit <number> --title "<title>" --body "<body>"
```
**If no PR exists:**
1. Show the generated title and body clearly:
```
Title: [the title]
```
Then the full body in a fenced code block.
2. Ask the user: "Create this PR?"
3. If the user confirms, run:
```bash
gh pr create --base <base-branch> --title "<title>" --body "<body>"
```
In both cases, if there were uncommitted changes found in Step 2, warn the user about them before proceeding.
## Important Notes
- Always diff against the base branch, not just the previous commit. The PR should describe ALL work on the branch.
- Read the actual diff content — don't just summarize file names. Understand what changed semantically.
- Keep the summary concise. The diff is available in the PR; the summary should help reviewers understand intent and scope quickly.
- Always ask before creating or updating a PR. Never run `gh pr create` or `gh pr edit` without user confirmation.
- Never run `git push`. The user handles pushing themselves. If the branch is not pushed, remind them to push first.

View File

@@ -0,0 +1,139 @@
---
name: spec-from-audit
description: Generate one spec per issue — repo file (canonical) + Linear document (mirror). Agent-executable implementation plans with file paths, validation commands, and acceptance criteria.
disable-model-invocation: false
user-invocable: true
argument-hint: '[issue IDs or path-to-audit-doc]'
---
# Spec from Audit
You take Linear issues (created by `/gaps-to-issues`) and generate one spec per issue. Each spec is written to the repo (`specs/`) as the canonical version, then mirrored to a Linear document for cross-tool access. A new Claude Code session with no prior context should be able to pick up the repo spec and produce a correct PR.
## Input
`$ARGUMENTS` — Either:
- A list of Linear issue IDs (e.g., `SELF-2357 SELF-2358 SELF-2359`)
- A path to an audit doc that has a "Follow-up Issues" section
If not provided, look for the most recently modified audit doc in `docs/reviews/` and extract issue IDs from it.
## Workflow
### Step 1: Gather Issue Context
For each issue ID, fetch the issue details:
- Use `mcp__linear-server__get_issue` to read the title, description, and acceptance criteria
- If an audit doc path is available, read the full audit doc for additional context on findings
### Step 2: Read the Codebase
For each issue, read the files referenced in its description/scope. You need to understand the **current state** of the code to write accurate specs with line numbers.
Do not guess file contents — read them. Specs with wrong line numbers or stale code references are worse than no spec.
### Step 3: Generate Specs
For each issue, write the spec to **both** locations:
1. **Repo file** — Write to `specs/projects/sdk/workstreams/<scope>/plans/<ID>-<slug>.md` using the Write tool. Determine `<scope>` from the workstream the issue belongs to (e.g., `webview`, `sdk-core`, `build-pipeline`). Always create or update the backlog row in the workstream's `SPEC.md` — if `SPEC.md` doesn't exist yet, create it.
2. **Linear document** — Create using `mcp__linear-server__create_document`, linked to the **issue** (not the project). This is the cross-tool access copy.
**Title format:** `SPEC: <issue title>`
**Spec structure:**
```markdown
## Overview
You are [doing what] in [which area]. [1-2 sentences on why this matters.]
## Preconditions
- [What must be true before starting — dependencies on other PRs, packages published, etc.]
## [Problem 1 / Area 1]: [descriptive name]
**File:** `path/to/file.ts`
[Description of the current problem with exact line numbers.]
### Fix
[Step-by-step instructions. Be explicit about what to change and why.]
## [Problem 2 / Area 2]: [descriptive name]
...
## Files to modify
- `path/to/file.ts` — [what changes]
- `path/to/other.ts` — [what changes]
## Files NOT to modify
- `path/to/untouched/` — [why]
## Dependencies
- [Other issues/PRs that must land first, if any]
## Validation
```bash
[relevant validation commands from the repo]
```
## Definition of Done
- [ ] [acceptance criterion from the issue]
- [ ] [acceptance criterion]
- [ ] All validation commands pass
```
### Spec-Writing Rules
The cardinal rule: **if two reasonable engineers could implement different solutions from the same spec, the spec is too open.** Specs must contain decisions, not options.
Follow these strictly:
1. **Decisions, not options** — "Use local wrappers" not "Consider adding to Euclid or using local wrappers." Every ambiguous implementation choice must be resolved in the spec. If you genuinely can't decide, flag it as a blocker and ask the user — don't embed it as an option.
2. **Second person** — "You are fixing...", "You will modify..."
3. **Exact file paths with line numbers** — `src/utils/sumsubProvider.ts:118`, not "the provider file"
4. **Current code, not stale references** — you read the files in Step 2, use what you actually saw
5. **Explicit constraints** — "You will NOT modify..." sections prevent scope creep
6. **Required vs optional** — mark every item. Don't let agents infer priority.
7. **Validation command** — agents will run it. If it's not there, they'll skip validation.
8. **One spec = one PR ≤2k LOC** — if a spec feels like it would produce >2k LOC, flag it and suggest splitting
9. **Self-contained** — the spec must include enough context to execute without reading other specs or the full audit doc
Every spec should follow this structure:
1. State the goal in one sentence
2. List decisions already made
3. Define scope and out-of-scope
4. Name the files to modify
5. Define done criteria in behavior terms
### Step 4: Present Results
Show the user:
1. Table of created specs with issue IDs and document URLs
2. Any issues that were too complex and need manual spec-writing
3. Any issues where codebase reading revealed the problem is already fixed or different than described
**Stop here.**
## Important Notes
- Never run `git commit` or `git push`.
- Always read the actual files before writing specs — stale line numbers are actively harmful.
- If an issue references files that don't exist, flag it rather than guessing.
- If you discover the issue description is wrong (e.g., the code was already fixed), note this and ask the user whether to still create the spec. If the user wants the issue updated, add a **comment** via `save_comment` explaining what changed — never overwrite the issue description.
- Specs are for agents, not humans. Write them as precise instructions, not explanatory documents.
- The spec is the source of truth, not the issue body. Issue bodies are lightweight pointers — the spec must be fully self-contained. Do not assume the agent has read the issue description.
- The repo file (`specs/`) is the canonical version. The Linear document is a copy for cross-tool access. Both should have identical content.
- Link Linear documents to issues (not projects). Use `mcp__linear-server__create_document` with the `issue` parameter.
- **Never overwrite issue descriptions.** Use `save_comment` for all updates, corrections, and status notes. Use `save_issue` only to change structured fields (status, priority, assignee).

View File

@@ -1,11 +1,19 @@
### Description
## Summary
_A brief description of the changes, what and how is being changed._
<!-- Brief description of changes -->
### Tested
## Test plan
_Explain how the change has been tested (for example by manual testing, unit tests etc) or why it's not necessary (for example version bump)._
<!-- How was this tested? -->
### How to QA
---
_How can the change be tested in a repeatable manner?_
### Native Consolidation Checklist
<!-- Check items that apply to this PR. Delete section if not touching native code. -->
- [ ] CONTRACTS.md reviewed - no unintended contract changes
- [ ] Layer 1 bridge contract tests pass (`cd app && yarn jest:run` / `yarn workspace @selfxyz/rn-sdk-test-app test`)
- [ ] Layer 3 builds pass (app iOS, RN test app iOS, RN test app Android)
- [ ] Layer 4 manual smoke test signed off (if consolidation PR)
- [ ] No new native business logic added (logic belongs in TypeScript)

View File

@@ -715,7 +715,7 @@ jobs:
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SUMSUB_TEE_URL: ${{ secrets.SUMSUB_TEE_URL }}
DIDIT_TEE_URL: ${{ secrets.DIDIT_TEE_URL }}
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}
@@ -1176,7 +1176,7 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=6144"
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SUMSUB_TEE_URL: ${{ secrets.SUMSUB_TEE_URL }}
DIDIT_TEE_URL: ${{ secrets.DIDIT_TEE_URL }}
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}

View File

@@ -39,6 +39,32 @@ jobs:
- name: Build webview-app
run: yarn workspace @selfxyz/webview-app build
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build common
run: yarn workspace @selfxyz/common build
- name: Build mobile-sdk-alpha
run: yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Build webview-bridge
run: yarn workspace @selfxyz/webview-bridge build
- name: Run linter
run: yarn workspace @selfxyz/webview-app lint
format:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Check Prettier formatting
run: yarn workspace @selfxyz/webview-app fmt
types:
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -53,4 +79,4 @@ jobs:
- name: Build webview-bridge
run: yarn workspace @selfxyz/webview-bridge build
- name: Typecheck
run: yarn workspace @selfxyz/webview-app typecheck
run: yarn workspace @selfxyz/webview-app types

View File

@@ -6,12 +6,16 @@ permissions:
on:
pull_request:
paths:
- "common/**"
- "packages/mobile-sdk-alpha/**"
- "packages/webview-bridge/**"
- ".github/workflows/webview-bridge-ci.yml"
- ".github/actions/**"
push:
branches: [dev, staging, main]
paths:
- "common/**"
- "packages/mobile-sdk-alpha/**"
- "packages/webview-bridge/**"
- ".github/workflows/webview-bridge-ci.yml"
- ".github/actions/**"
@@ -31,6 +35,30 @@ jobs:
- name: Build
run: yarn workspace @selfxyz/webview-bridge build
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build common
run: yarn workspace @selfxyz/common build
- name: Build mobile-sdk-alpha
run: yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Run linter
run: yarn workspace @selfxyz/webview-bridge lint
format:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Check Prettier formatting
run: yarn workspace @selfxyz/webview-bridge fmt
types:
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -43,7 +71,7 @@ jobs:
- name: Build mobile-sdk-alpha
run: yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Typecheck
run: yarn workspace @selfxyz/webview-bridge typecheck
run: yarn workspace @selfxyz/webview-bridge types
test:
runs-on: ubuntu-latest

5
.gitignore vendored
View File

@@ -13,7 +13,10 @@ output/*
*.tsbuildinfo
.yarnrc.yml
package-lock.json
.claude
.claude/*
!.claude/skills/
**/.claude/settings.json
**/.claude/settings.local.json
# CI-generated tarballs (don't commit these!)
mobile-sdk-alpha-ci.tgz

View File

@@ -10,5 +10,6 @@ circuits/build/**
contracts/artifacts/**
contracts/cache/**
contracts/typechain-types/**
packages/webview-app/public/animations/**
.nvmrc
.watchmanconfig

View File

@@ -43,60 +43,60 @@ nvm use && corepack enable && yarn install
- **No regressions in the RN app.** Every change to `mobile-sdk-alpha` must be backwards-compatible with the existing Self Wallet app.
- **Specs stay current.** When implementation deviates from the spec, update the spec. A stale spec is worse than no spec.
- **Constraint tie-breaker.** If rules conflict: correctness and security first, then scope/clarity (small PRs, small files), then reuse. Document the tradeoff in the spec.
- **Linear issue descriptions are immutable after creation.** Never overwrite an issue description with `save_issue` to add updates, status notes, or context. Issue descriptions are the original scope set at creation time. All subsequent updates — status changes, progress notes, discovered context, blockers, decision records — go in **comments** via `save_comment`. The only valid use of `save_issue` on an existing issue is to change structured fields (status, priority, assignee, labels). If you need to correct a factual error in the description, add a comment explaining the correction rather than silently rewriting history.
## Specs & Planning
**Every feature — even minor ones — uses the spec system.** Before implementing, read the relevant specs, write a plan to disk, then execute. No exceptions. A plan that only exists in session memory is a plan that will be lost.
**Every feature — even minor ones — needs a spec.** For SDK work (`packages/`, `webview-app`, `webview-bridge`), specs live in **both** the repo (`specs/`) and Linear. The repo spec is the canonical, version-controlled execution plan. The Linear issue is the tracking and discovery layer. For app-only or non-SDK work, a Linear issue with inline scope is sufficient — no repo spec required.
### Spec System (`specs/`)
### Where Specs Live
| File | Purpose | When to Read |
| ------------------------------------------------ | ------------------------------------------- | ------------------------ |
| [Specs README](./specs/README.md) | Table of contents, reading order | First. Always. |
| [Templates](./specs/framework/TEMPLATES.md) | Copy-paste templates for all three tiers | When creating a new spec |
| [SDK Overview](./specs/projects/sdk/OVERVIEW.md) | Architecture, bridge protocol, module table | For system-level context |
Workstream specs live in `specs/projects/sdk/workstreams/*/` with `SPEC.md` (living implementation details).
### Spec-Reading Protocol (for chunk execution)
To execute a chunk:
1. Read `specs/projects/sdk/INDEX.md` — find your workstream
2. Read the workstream `SPEC.md` — find your chunk
3. If you need architecture context, read the project `OVERVIEW.md`
That's it. Do not read framework docs unless you are writing a new spec.
- **Execution specs → `specs/projects/sdk/workstreams/<scope>/plans/<ID>-<slug>.md`** — version-controlled, agent-executable plans
- **Backlog → `specs/projects/sdk/workstreams/<scope>/SPEC.md`** — durable context plus backlog table per workstream
- **Architecture context → `specs/projects/sdk/OVERVIEW.md`** — system architecture, bridge protocol, decision matrix
- **Audit docs → `docs/reviews/`** — PR audit findings, kept in repo for git history
- **Linear issues** — tracking, discovery, status. Link to the repo spec. Attach a Linear document copy for cross-tool access.
### Planning Protocol
1. **Read** the relevant workstream specs and this file's Key Rules — understand the current state and constraints
2. **Write a plan to disk** — use the appropriate tier from `specs/framework/TEMPLATES.md`:
- **Large features / new workstreams:** Create a full implementation spec (`specs/projects/sdk/workstreams/<scope>/SPEC.md`)
- **Medium features / multi-chunk work:** Create a plan file in `workstreams/<scope>/plans/` named `<BACKLOG-ID>-<slug>.md` and link it from the backlog in the relevant `SPEC.md`
- **Small features / single-chunk fixes:** Create a minimal plan file in `workstreams/<scope>/plans/` named `<BACKLOG-ID>-<slug>.md` or add the chunk to an existing active plan
3. **Include in every plan:** scope of work, files modified, I/O examples, validation command, definition of done
4. **Then implement** — update chunk status as you complete work
5. **After completion:** Mark chunks done in SPEC.md status tables. Review status checklists at session start — if something is marked "Done" that isn't, or "Pending" that's in progress, fix it first.
1. **Read** this file's Key Rules and any relevant specs — understand the current state and constraints
2. **Create a Linear issue** if one doesn't exist — include scope, files modified, acceptance criteria
3. **Write the spec** in `specs/` following the two-layer model (backlog row in `SPEC.md`, execution plan in `plans/`)
4. **Create a Linear document** attached to the issue with the spec content (so non-GitHub users can review)
5. **Then implement** — one spec = one PR (see PR size target in Key Rules)
6. **After completion:** Update the Linear issue status via `save_issue` (status field only). Add a **comment** via `save_comment` summarizing what was done, linking the PR. Close when done.
### Spec-Writing Guidelines
When writing specs, follow these principles so they work as AI agent prompts:
Specs are agent-executable prompts. A new Claude Code session with no prior context must be able to pick up the spec and produce a correct PR.
- **Use second person.** "You are making X portable" not "X should be made portable."
- **Make decisions, not options.** "Use local wrappers" not "Consider adding to Euclid or using local wrappers." Agents can't choose between approaches — tell them which one.
- **Use second person.** "You are fixing X" not "X should be fixed."
- **Be explicit about constraints.** "You will NOT modify..." not just "Focus on..."
- **Provide exact file paths with line numbers.** `src/proving/provingMachine.ts:543` not "the proving machine file."
- **Provide exact file paths with line numbers.** `src/utils/sumsubProvider.ts:118` not "the provider file."
- **State the validation command.** Agents will run it. If it's not there, they'll skip validation.
- **One chunk = one self-contained prompt.** The chunk must include enough context to execute without reading the full spec.
- **One PR = one plan file.** A plan file is the execution handoff. It must be self-contained enough that a new agent can pick it up after session loss.
- **Use `--remote` for M and L chunks.** Medium and large chunks benefit from `claude --remote` so work continues in the background.
- **One spec = one PR.** Target the PR size from Key Rules (1k3k LOC). If a spec would exceed that, split it.
- **Mark items as required vs optional.** Don't let agents infer priority.
- **Include out-of-scope sections.** These are as important as in-scope sections for preventing drift.
- **Use `--remote` for medium+ work.** Medium and large specs benefit from `claude --remote` so work continues in the background.
### Why Even Minor Features
### Audit Pipeline Skills
Three Claude Code skills automate the review-to-implementation pipeline:
1. **`/pr-audit`** — Multi-agent review (component + integration + routing), produces audit doc in `docs/reviews/`
2. **`/gaps-to-issues`** — Creates Linear issues from audit PR buckets
3. **`/spec-from-audit`** — Generates agent-executable specs (repo file + Linear document), one per issue
Run them in sequence with review pauses between each step.
### Why Specs
- Prevents scope creep — writing "files NOT modified" forces focus
- Survives session loss — API errors, context overflow, `/clear` won't destroy the plan
- Enables parallel work — multiple agents can pick up chunks from the same plan
- Creates audit trail — what was planned vs what was built
- Survives session loss — specs live in the repo and Linear, not session memory
- Enables parallel work — multiple agents can pick up specs from the same project
- Creates audit trail — what was planned vs what was built (version-controlled in git)
- Enables cross-tool review — Linear documents let non-GitHub users review specs
## Validation Commands
@@ -120,5 +120,6 @@ yarn lint && yarn types && yarn build
## Workspace-Specific Instructions
- `app/AGENTS.md` — Mobile app development, E2E testing, deployment
- `packages/webview-app/AGENTS.md` — WebView app development, Euclid screen migration, asset management
- `packages/mobile-sdk-alpha/AGENTS.md` — SDK development, testing guidelines
- `noir/AGENTS.md` — Noir circuit development

View File

@@ -286,9 +286,19 @@ grep -r "require('react-native')" app/tests/
See `.cursor/rules/test-memory-optimization.mdc` for comprehensive guidelines, examples, and anti-patterns.
## Linear Issue Interaction
When working with Linear issues during development:
- **`save_comment`** for: status updates, progress notes, blockers, linking PRs, corrections, decision records
- **`save_issue`** for: changing status, priority, assignee, labels (structured fields only)
- **`create_document`** for: attaching specs as Linear documents
**Never overwrite an issue description.** Descriptions are the original scope set at creation time. All subsequent context goes in comments. If the description has a factual error, add a comment explaining the correction — do not silently rewrite it.
## SDK Architecture
The Self Wallet app serves as a **test environment** for the SDK refactor. For SDK architecture context:
- **[SDK Overview](../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix
- **[SDK Project Index](../specs/projects/sdk/INDEX.md)** — Workstream links and entry point
- **[SDK Overview](../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix (read-only reference)
- **Implementation specs** — Canonical source is `specs/projects/sdk/workstreams/<scope>/plans/` (version-controlled). Linear documents attached to issues are mirrored copies for tracking/discovery. When in doubt, trust the repo spec.

View File

@@ -42,7 +42,7 @@ allprojects {
url("$rootDir/../../node_modules/jsc-android/dist")
}
maven { url 'https://jitpack.io' }
maven { url "https://maven.sumsub.com/repository/maven-public/" }
maven { url "https://raw.githubusercontent.com/didit-protocol/sdk-android/main/repository" }
}
configurations.configureEach {
resolutionStrategy.dependencySubstitution {

View File

@@ -10,4 +10,4 @@ IS_TEST_BUILD=
MIXPANEL_NFC_PROJECT_TOKEN=
SEGMENT_KEY=
SENTRY_DSN=
SUMSUB_TEE_URL=
DIDIT_TEE_URL=

View File

@@ -8,6 +8,9 @@ export const DEFAULT_DOE = undefined;
export const DEFAULT_PNUMBER = undefined;
export const DIDIT_TEE_URL =
process.env.DIDIT_TEE_URL || 'http://localhost:8080';
export const ENABLE_DEBUG_LOGS = process.env.ENABLE_DEBUG_LOGS === 'true';
export const GOOGLE_SIGNIN_ANDROID_CLIENT_ID =
@@ -18,22 +21,18 @@ export const GOOGLE_SIGNIN_IOS_CLIENT_ID =
export const GOOGLE_SIGNIN_WEB_CLIENT_ID =
process.env.GOOGLE_SIGNIN_WEB_CLIENT_ID;
export const GRAFANA_LOKI_PASSWORD = process.env.GRAFANA_LOKI_PASSWORD;
export const GRAFANA_LOKI_URL = process.env.GRAFANA_LOKI_URL;
export const GRAFANA_LOKI_USERNAME = process.env.GRAFANA_LOKI_USERNAME;
/* This file provides compatiblity between how web expects env variables to be and how native does.
* on web it is aliased to @env on native it is not used
*/
export const IS_TEST_BUILD = process.env.IS_TEST_BUILD === 'true';
export const MIXPANEL_NFC_PROJECT_TOKEN = undefined;
export const SEGMENT_KEY = process.env.SEGMENT_KEY;
export const SENTRY_DSN = process.env.SENTRY_DSN;
export const SUMSUB_TEE_URL =
process.env.SUMSUB_TEE_URL || 'http://localhost:8080';
export const SUMSUB_TEST_TOKEN = process.env.SUMSUB_TEST_TOKEN;
export const TURNKEY_AUTH_PROXY_CONFIG_ID =
process.env.TURNKEY_AUTH_PROXY_CONFIG_ID;

View File

@@ -10,7 +10,7 @@
import Foundation
import React
#if !E2E_TESTING
import NFCPassportReader
import SelfNFCPassportReader
import Mixpanel
#endif
import Sentry
@@ -19,11 +19,11 @@ import Sentry
@available(iOS 15, *)
@objc(PassportReader)
class PassportReader: NSObject {
private var passportReader: NFCPassportReader.PassportReader
private var passportReader: SelfNFCPassportReader.PassportReader
private var analytics: SelfAnalytics?
override init() {
self.passportReader = NFCPassportReader.PassportReader()
self.passportReader = SelfNFCPassportReader.PassportReader()
super.init()
}
@@ -58,7 +58,7 @@ class PassportReader: NSObject {
func configure(token: String, enableDebugLogs: Bool) {
let analytics = SelfAnalytics(token: token, enableDebugLogs: enableDebugLogs)
self.analytics = analytics
self.passportReader = NFCPassportReader.PassportReader(analytics: analytics)
self.passportReader = SelfNFCPassportReader.PassportReader(analytics: analytics)
}
@objc(trackEvent:properties:)

View File

@@ -5,7 +5,7 @@
import Foundation
import React
#if !E2E_TESTING
import NFCPassportReader
import SelfNFCPassportReader
import Security
@available(iOS 13, macOS 10.15, *)
@@ -77,7 +77,7 @@ enum PassportReaderCore {
}
static func scanPassport(
reader: NFCPassportReader.PassportReader,
reader: SelfNFCPassportReader.PassportReader,
passportNumber: String,
dateOfBirth: String,
dateOfExpiry: String,

View File

@@ -1,20 +1,5 @@
source "https://cdn.cocoapods.org/"
# Skip Sumsub configuration for E2E testing
unless ENV["E2E_TESTING"] == "1"
source "https://github.com/SumSubstance/Specs.git"
# Enable Fisherman (Device Intelligence) module for fraud detection
# Privacy: Device ID collection declared in app/ios/PrivacyInfo.xcprivacy
ENV["IDENSIC_WITH_FISHERMAN"] = "true"
# VideoIdent module disabled for current release
# This feature provides liveness checks via live video calls with human agents
# Disabled to avoid microphone permission requirements on both platforms
# TODO: Re-enable for future release when liveness checks are needed
# ENV["IDENSIC_WITH_VIDEOIDENT"] = "true"
end
use_frameworks!
require "tmpdir"
@@ -52,14 +37,6 @@ def simulator_arm64_audit_entries
name: "FingerprintPro",
path: "Pods/FingerprintPro/FingerprintPro.xcframework",
},
{
name: "IdensicMobileSDK",
path: "Pods/IdensicMobileSDK/IdensicMobileSDK.xcframework",
},
{
name: "IdensicMobileSDK_Fisherman",
path: "Pods/IdensicMobileSDK/IdensicMobileSDK_Fisherman.xcframework",
},
{
name: "OpenSSL",
path: "Pods/OpenSSL-Universal/Frameworks/OpenSSL.xcframework",
@@ -106,14 +83,14 @@ target "Self" do
use_expo_modules!
# Native module exclusion for E2E testing is handled in react-native.config.cjs
config = use_native_modules!
# Skip NFCPassportReader for e2e testing to avoid build issues
# Skip SelfNFCPassportReader for e2e testing to avoid build issues
unless ENV["E2E_TESTING"] == "1"
# Check if we're running in a selfxyz repo or an external fork
is_selfxyz_repo = ENV["GITHUB_REPOSITORY"]&.start_with?("selfxyz/") || ENV["GITHUB_REPOSITORY"].nil?
if !is_selfxyz_repo
# External fork - use public NFCPassportReader repository (placeholder)
# TODO: Replace with actual public NFCPassportReader repository URL
# External fork - use public SelfNFCPassportReader repository (placeholder)
# TODO: Replace with actual public SelfNFCPassportReader repository URL
nfc_repo_url = "https://github.com/PLACEHOLDER/NFCPassportReader.git"
elsif ENV["GITHUB_ACTIONS"] == "true"
# CI: NEVER embed credentials in URLs. Rely on workflow-provided auth via:
@@ -127,15 +104,17 @@ target "Self" do
nfc_repo_url = "git@github.com:selfxyz/NFCPassportReader.git"
end
pod "NFCPassportReader", git: nfc_repo_url, commit: "9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b"
pod "SelfNFCPassportReader", git: nfc_repo_url, commit: "2cdc50a5c27b75594b94b27fdc4bb6172ada0f96"
end
# Explicitly declare Mixpanel to ensure it's available even in E2E builds
# (NFCPassportReader also includes Mixpanel, but is skipped during E2E testing)
# (SelfNFCPassportReader also includes Mixpanel, but is skipped during E2E testing)
pod "Mixpanel-swift", "~> 5.0.0", :modular_headers => true
pod "QKMRZScanner"
pod "lottie-ios"
pod 'DiditSDK', :podspec => './local-pods/DiditSDK/DiditSDK.podspec'
pod 'OpenSSL-Universal', '~> 1.1.1900'
pod "SwiftQRScanner", :git => "https://github.com/vinodiOS/SwiftQRScanner"
# RNReactNativeHapticFeedback is handled by autolinking
@@ -190,7 +169,7 @@ target "Self" do
system(command)
end
# Only strip OpenSSL bitcode if NFCPassportReader is included (not in e2e testing)
# Only strip OpenSSL bitcode if SelfNFCPassportReader is included (not in e2e testing)
unless ENV["E2E_TESTING"] == "1"
framework_paths = [
"Pods/OpenSSL-Universal/Frameworks/OpenSSL.xcframework/ios-arm64/OpenSSL.framework/OpenSSL",
@@ -228,6 +207,7 @@ target "Self" do
config.build_settings["GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS"] = "NO"
end
end
end
# update QKCutoutView.swift to hide OCR border
@@ -254,6 +234,22 @@ target "Self" do
end
end
# Fix fmt consteval error on Xcode 26 — clang enables FMT_USE_CONSTEVAL but
# has a bug evaluating consteval calls. Patch the detection block in base.h to
# always resolve to 0. Safe on all Xcode versions (fmt falls back to constexpr).
fmt_base_h = "Pods/fmt/include/fmt/base.h"
if File.exist?(fmt_base_h)
system("chmod u+w #{fmt_base_h}")
text = File.read(fmt_base_h)
unless text.include?("// Patched: force-disable FMT_USE_CONSTEVAL")
patched = text.gsub(
/^# define FMT_USE_CONSTEVAL 1.*$/,
"# define FMT_USE_CONSTEVAL 0 // Patched: force-disable FMT_USE_CONSTEVAL"
)
File.write(fmt_base_h, patched) if patched != text
end
end
# Add E2E_TESTING compilation condition for main app target when environment variable is set
if ENV["E2E_TESTING"] == "1"
# Find Self.xcodeproj and add E2E_TESTING compilation condition

View File

@@ -12,6 +12,8 @@ PODS:
- boost (1.84.0)
- BVLinearGradient (2.8.3):
- React-Core
- DiditSDK (3.2.6):
- OpenSSL-Universal (~> 1.1.1900)
- DoubleConversion (1.1.6)
- EXApplication (6.0.2):
- ExpoModulesCore
@@ -57,7 +59,6 @@ PODS:
- Yoga
- fast_float (6.1.4)
- FBLazyVector (0.77.0)
- FingerprintPro (2.13.0)
- Firebase/CoreOnly (11.11.0):
- FirebaseCore (~> 11.11.0)
- Firebase/Messaging (11.11.0):
@@ -141,14 +142,6 @@ PODS:
- hermes-engine (0.77.0):
- hermes-engine/Pre-built (= 0.77.0)
- hermes-engine/Pre-built (0.77.0)
- IdensicMobileSDK (1.40.2):
- IdensicMobileSDK/Default (= 1.40.2)
- IdensicMobileSDK/Core (1.40.2)
- IdensicMobileSDK/Default (1.40.2):
- IdensicMobileSDK/Core
- IdensicMobileSDK/Fisherman (1.40.2):
- FingerprintPro (~> 2.11)
- IdensicMobileSDK/Core
- lottie-ios (4.5.0)
- lottie-react-native (7.2.2):
- DoubleConversion
@@ -180,9 +173,6 @@ PODS:
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- NFCPassportReader (2.1.1):
- Mixpanel-swift (~> 5.0.0)
- OpenSSL-Universal (= 1.1.1900)
- OpenSSL-Universal (1.1.1900)
- PromisesObjC (2.4.0)
- QKMRZParser (2.0.0)
@@ -1461,10 +1451,6 @@ PODS:
- Yoga
- react-native-get-random-values (1.11.0):
- React-Core
- react-native-mobilesdk-module (1.40.2):
- IdensicMobileSDK (= 1.40.2)
- IdensicMobileSDK/Fisherman (= 1.40.2)
- React-Core
- react-native-netinfo (11.4.1):
- React-Core
- react-native-nfc-manager (3.17.2):
@@ -2145,9 +2131,34 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- SdkReactNative (3.2.7):
- DiditSDK (~> 3.2)
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- segment-analytics-react-native (2.21.4):
- React-Core
- sovran-react-native
- SelfNFCPassportReader (2.1.1):
- Mixpanel-swift (~> 5.0.0)
- OpenSSL-Universal (= 1.1.1900)
- Sentry/HybridSDK (8.53.2)
- SocketRocket (0.7.1)
- sovran-react-native (1.1.3):
@@ -2159,6 +2170,7 @@ PODS:
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DiditSDK (from `./local-pods/DiditSDK/DiditSDK.podspec`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXApplication (from `../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
@@ -2177,7 +2189,7 @@ DEPENDENCIES:
- lottie-ios
- lottie-react-native (from `../node_modules/lottie-react-native`)
- Mixpanel-swift (~> 5.0.0)
- "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`)"
- OpenSSL-Universal (~> 1.1.1900)
- QKMRZScanner
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
@@ -2216,7 +2228,6 @@ DEPENDENCIES:
- react-native-cloud-storage (from `../node_modules/react-native-cloud-storage`)
- "react-native-compat (from `../node_modules/@walletconnect/react-native-compat`)"
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- "react-native-mobilesdk-module (from `../node_modules/@sumsub/react-native-mobilesdk-module`)"
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-nfc-manager (from `../node_modules/react-native-nfc-manager`)
- react-native-passkey (from `../node_modules/react-native-passkey`)
@@ -2268,18 +2279,17 @@ DEPENDENCIES:
- RNScreens (from `../node_modules/react-native-screens`)
- "RNSentry (from `../node_modules/@sentry/react-native`)"
- RNSVG (from `../node_modules/react-native-svg`)
- "SdkReactNative (from `../node_modules/@didit-protocol/sdk-react-native`)"
- "segment-analytics-react-native (from `../node_modules/@segment/analytics-react-native`)"
- "SelfNFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `2cdc50a5c27b75594b94b27fdc4bb6172ada0f96`)"
- "sovran-react-native (from `../node_modules/@segment/sovran-react-native`)"
- SwiftQRScanner (from `https://github.com/vinodiOS/SwiftQRScanner`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
https://github.com/SumSubstance/Specs.git:
- IdensicMobileSDK
trunk:
- AppAuth
- AppCheckCore
- FingerprintPro
- Firebase
- FirebaseABTesting
- FirebaseCore
@@ -2311,6 +2321,8 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
BVLinearGradient:
:path: "../node_modules/react-native-linear-gradient"
DiditSDK:
:podspec: "./local-pods/DiditSDK/DiditSDK.podspec"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXApplication:
@@ -2344,9 +2356,6 @@ EXTERNAL SOURCES:
:tag: hermes-2024-11-25-RNv0.77.0-d4f25d534ab744866448b36ca3bf3d97c08e638c
lottie-react-native:
:path: "../node_modules/lottie-react-native"
NFCPassportReader:
:commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b
:git: "git@github.com:selfxyz/NFCPassportReader.git"
RCT-Folly:
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTDeprecation:
@@ -2417,8 +2426,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/@walletconnect/react-native-compat"
react-native-get-random-values:
:path: "../node_modules/react-native-get-random-values"
react-native-mobilesdk-module:
:path: "../node_modules/@sumsub/react-native-mobilesdk-module"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-nfc-manager:
@@ -2521,8 +2528,13 @@ EXTERNAL SOURCES:
:path: "../node_modules/@sentry/react-native"
RNSVG:
:path: "../node_modules/react-native-svg"
SdkReactNative:
:path: "../node_modules/@didit-protocol/sdk-react-native"
segment-analytics-react-native:
:path: "../node_modules/@segment/analytics-react-native"
SelfNFCPassportReader:
:commit: 2cdc50a5c27b75594b94b27fdc4bb6172ada0f96
:git: "git@github.com:selfxyz/NFCPassportReader.git"
sovran-react-native:
:path: "../node_modules/@segment/sovran-react-native"
SwiftQRScanner:
@@ -2531,8 +2543,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
CHECKOUT OPTIONS:
NFCPassportReader:
:commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b
SelfNFCPassportReader:
:commit: 2cdc50a5c27b75594b94b27fdc4bb6172ada0f96
:git: "git@github.com:selfxyz/NFCPassportReader.git"
SwiftQRScanner:
:commit: c71ff91297640a944de4bca61434155c3f9b0979
@@ -2542,20 +2554,20 @@ SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
BVLinearGradient: 880f91a7854faff2df62518f0281afb1c60d49a3
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
DiditSDK: 3113b1aa1e5f67e84e84bd8449273257e5d9eff0
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXApplication: 27a524f5c3e671c6218220fba04752629466a1a9
EXConstants: a1f35b9aabbb3c6791f8e67722579b1ffcdd3f18
Expo: 03dca15247583ca0d09d3cee37fb2d5a0b878f04
ExpoAdapterGoogleSignIn: 3332ac2d96d803350f53f84047244b35e2efc994
ExpoAsset: 0687fe05f5d051c4a34dd1f9440bd00858413cfe
ExpoFileSystem: c8c19bf80d914c83dda3beb8569d7fb603be0970
ExpoFont: 773955186469acc5108ff569712a2d243857475f
ExpoKeepAwake: 2a5f15dd4964cba8002c9a36676319a3394c85c7
ExpoModulesCore: 0b8556860296c2ac2a0f393764c9ef78170a1558
EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819
EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b
Expo: 4bb70893882e6382b41d1e910d7226c6a1b85f0a
ExpoAdapterGoogleSignIn: ab4d9fc38cb91077a4138d178395525ec65d0c2e
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655
ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188
ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680
ExpoModulesCore: bcee92d3a2c68c408b2d8da43e3094109340dc17
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 2bc03a5cf64e29c611bbc5d7eb9d9f7431f37ee6
FingerprintPro: 2f419138022451a72f783db9c94967f5a68e9977
Firebase: 6a8f201c61eda24e98f1ce2b44b1b9c2caf525cc
FirebaseABTesting: 8551c24eb28e300ce697f8eb72c1a519bb96eb40
FirebaseCore: 2321536f9c423b1f857e047a82b8a42abc6d9e2c
@@ -2574,12 +2586,10 @@ SPEC CHECKSUMS:
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
hermes-engine: 1f783c3d53940aed0d2c84586f0b7a85ab7827ef
IdensicMobileSDK: 00b13320e1b1e0574e68475bd0fbc7cd30fce26e
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: 57ffc63285c1e5dcad6743dca0d3e1ed34cac598
lottie-react-native: 6cb05b7b4ea463afe657e3b46784f067858e1a5d
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
NFCPassportReader: 48873f856f91215dbfa1eaaec20eae639672862e
OpenSSL-Universal: 84efb8a29841f2764ac5403e0c4119a28b713346
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
QKMRZParser: 6b419b6f07d6bff6b50429b97de10846dc902c29
@@ -2590,95 +2600,96 @@ SPEC CHECKSUMS:
RCTTypeSafety: 50d6ec72a3d13cf77e041ff43a0617050fb98e3f
React: e46fdbd82d2de942970c106677056f3bdd438d82
React-callinvoker: b027ad895934b5f27ce166d095ed0d272d7df619
React-Core: 36b7f20f655d47a35046e2b02c9aa5a8f1bcb61e
React-CoreModules: 7fac6030d37165c251a7bd4bde3333212544da3c
React-cxxreact: 0ead442ecaa248e7f71719e286510676495ae26d
React-Core: 92733c8280b1642afed7ebfb3c523feaec946ece
React-CoreModules: e2dfd87b6fdb9d969b16871655885a4d89a2a9f4
React-cxxreact: d1a70e78543bb5b159fdaf6c52cadd33c1ae3244
React-debug: c17d400ddcb2c45aa4f5efedeb443c72b58b40aa
React-defaultsnativemodule: d8ddce2020fede6b0a6d3cccc3fbb1fedf1aab37
React-domnativemodule: 17da9148ba917807b9bab6c4e1fddbc11303be64
React-Fabric: fda27452bab6f8b5213f33c1d59a24f6c6b66579
React-FabricComponents: 10623f84dcb5ae9b2bbe98f577546b10fa459fdb
React-FabricImage: 2237e1c2089eb4e55541485e173f96af43afca7d
React-defaultsnativemodule: af13e4f2629106aede1d6286921f852715017d64
React-domnativemodule: b6785fc507cfcbdf24509a0be26fdac7454f7ea3
React-Fabric: 5f8c48a36ff906a0e8761ff914ef368f67a25b59
React-FabricComponents: 2ba16205b15ce80460a1dcc3725b3926493b47f8
React-FabricImage: d1b0c203284c0ab077277a54830e4de4c0134908
React-featureflags: 94805545eda554c548e3615f248f4f4c65ef279e
React-featureflagsnativemodule: b71dc56c26b09c5becaabc59d90eb6715a76d01e
React-graphics: f81c5369a01264f5e5f2ab7b2e7fbe769c94ed42
React-hermes: 13e1c1c9222503bcd7ad450370c5a26dc9b46ebe
React-idlecallbacksnativemodule: 16c2ade55cf3537f7d6d1afb7acb230d65b1d63c
React-ImageManager: 130248847aada2e9485db30cef63284ffc2f0846
React-jserrorhandler: ef0948d6835b991094660d93cb7dcf3446d065f5
React-jsi: 931610846e52e5d157f4bc3f71a14f9a53573abd
React-jsiexecutor: 3f5fb21d47c5c72c13a1710b288d78c8209a38f9
React-jsinspector: 231977808d975ea2ad045b910623651ef7219657
React-jsitracing: 9b717dd9c91915ccf51af10df94e8c38de722786
React-logger: 9a0c4e1e41cd640ac49d69aacadab783f7e0096b
React-Mapbuffer: 257e617e7554c0ec448d13d38b13ee3cbdd3c5eb
React-microtasksnativemodule: fa9db75d61e2053274057767ced1a2e2c485b0fa
react-native-app-auth: 9b0a0e3ca279c3426a451e2607c8483808b8ed4a
react-native-biometrics: 352e5a794bfffc46a0c86725ea7dc62deb085bdc
react-native-blur: 6782cb12b39a0200ad2a782fb9a5529c2c83c33b
react-native-cloud-storage: 8dc640aac2cf6e8a6231cc49696e8f8405b716bf
react-native-compat: c6ac08d44535eb2b3735a2491318136dbdecf271
react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
react-native-mobilesdk-module: 9c0b53eeea509a7ca1a6429632f2144a597ad824
react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac
react-native-nfc-manager: ef3b44c4f1975ab16d6109bb1671ab68068aba58
react-native-passkey: 12d9c47c848f939b608e1c2d31197312be53f71e
react-native-safe-area-context: 4a867695ce0b837b7fedc90c5629d4322be4222f
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-webview: de5205a97121427588aff27de2ddea4cc9fc0a19
React-featureflagsnativemodule: 0ab7272372052fe9dc561dc2e4bbd4fd8ab11ea4
React-graphics: 6800e73b337075ad0cb9226c1592ed1a91703244
React-hermes: bf50c8272cb562300a54a621aa69dc12a0b4fcf2
React-idlecallbacksnativemodule: 57d5b25440ed0478966710675354eac676508ff5
React-ImageManager: fff4c0c50041d7b8f67d6f435e7a4b1e9125ad27
React-jserrorhandler: 4abc5dfa7d5fb7bfba328faddfa97dc90441c276
React-jsi: 19e77567e235d06b7e8f425d2a6c1e948ab286e9
React-jsiexecutor: fe6ad8b9a2bf97e435fc1c969c80ed7f447ed68e
React-jsinspector: 01aa56b6037c65a6ec4432a120aa74cc6fdf514f
React-jsitracing: cb05a2c5c36eb212be028e26c38028f0d352c16b
React-logger: 02e5802824aa9b15cb7df42e10a91abead83cd8d
React-Mapbuffer: bbd3be71ef32e8198ac0f78b841662103e032ffe
React-microtasksnativemodule: 8e65fc37744388153b9bca94552d04955d852058
react-native-app-auth: e21c8ee920876b960e38c9381971bd189ebea06b
react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d
react-native-blur: 745703f35133ed6a1210d4bbff358a631911f002
react-native-cloud-storage: 796c793dc354bb49f9df27ca25eed0f79a15549e
react-native-compat: 10b5f906b469268eaceca83ea2393c177f1ce18a
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-nfc-manager: c8891e460b4943b695d63f7f4effc6345bbefc83
react-native-passkey: 8818f842d1b80e45c06e906a5c85964719782bf5
react-native-safe-area-context: 5b5d3eb6ec9ef848f16c064a4eab4a92c7d7895e
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-webview: 05734d99f1e422c5ddfeefbd083d53abd78fccb1
React-nativeconfig: 334c9961d74ddd3bc203afb92ee574ed01c7c755
React-NativeModulesApple: e55f72e014482edd711542815a98b865ee6de9a1
React-perflogger: 15a7bcb6c46eae8a981f7add8c9f4172e2372324
React-performancetimeline: 9fb03db27775ddef6a98e3d22811acf210f07ba4
React-NativeModulesApple: bf996c9e3b86e579e6e8635633b721c165a60b2c
React-perflogger: 721172bda31a65ce7b7a0c3bf3de96f12ef6f45d
React-performancetimeline: a23bfc89694e13ead855f25049bb9d60ce3704a2
React-RCTActionSheet: 25eb72eabade4095bfaf6cd9c5c965c76865daa8
React-RCTAnimation: 04c987fa858fa16169f543d29edb4140bd35afa9
React-RCTAppDelegate: b2707904e4f8ad92fd052e62684bf0c3b88381cc
React-RCTBlob: 1f214a7211632515805dd1f1b81fac70d12f812d
React-RCTFabric: 0838a13e11c221d1d5648257b2ca31fede22874b
React-RCTFBReactNativeSpec: 60d72b45a150ca35748b9a77028674b1e56a2e43
React-RCTImage: e516d72739797fb7c1dac5c691f02a0f5445c290
React-RCTLinking: 1e5554afe4f959696ad3285738c1510f2592f220
React-RCTNetwork: 65e1e52c8614dcab342fa1eaec750ca818160e74
React-RCTSettings: e86c204b481ef9264929fe00d1fdd04ce561748a
React-RCTText: 15f14d6f9b75e64ffe749c75e30ff047cf0fa1be
React-RCTVibration: 8d9078d5432972fe12d9f1526b38f504ad3d45cb
React-RCTAnimation: 8efbd0a4a71fd3dbe84e6d08b92bec5728b7524b
React-RCTAppDelegate: 8ff6da817adefd15d4e25ade53a477c344f9b213
React-RCTBlob: 6056bd62a56a6d2dad55cdf195949db1de623e14
React-RCTFabric: 113fe8b6532ac21a6a46700b2650b8d458020ee4
React-RCTFBReactNativeSpec: 4214925b1c4829fb1e73bfbacb301244b522dc11
React-RCTImage: 7b3f38c77e183bdcb43dbcd7b5842b96c814889a
React-RCTLinking: 6cca74db71b23f670b72e45603e615c2b72b2235
React-RCTNetwork: 5791b0718eff20c12f6f3d62e2ad50cff4b5c8a0
React-RCTSettings: 84154e31a232b5b03b6b7a89924a267c431ccf16
React-RCTText: cd49cb4442ee7f64b0415b27745d2495cb40cfaa
React-RCTVibration: 2a7432e61d42f802716bd67edc793b5e5f58971a
React-rendererconsistency: 9da9009da0eafdf005a77a260b1dbea274a90aa8
React-rendererdebug: bb56856ce3901396c959ddcf0991f7a3a162f4c5
React-rendererdebug: 4b9e70532888e08f41c5fcbcbc050e99a590839c
React-rncore: d380e5c97ec669c0bd097612cd98247597a32679
React-RuntimeApple: 559b3d8f068335e896224b8365fd8cee814e6652
React-RuntimeCore: 87c25d97233f61b68bb254360e2724c01eb93198
React-RuntimeApple: 0088247d510e7eb4a3a2ecc0964411266730d10d
React-RuntimeCore: 0e45d29ad4057b029db38e92ab24d4294253c6e3
React-runtimeexecutor: f9ae11481be048438640085c1e8266d6afebae44
React-RuntimeHermes: 0cba4a2b329dcb8392754dd20a839709c7e3389f
React-runtimescheduler: 62d73526c3471884a896328e11a930ea4b42dfe1
React-RuntimeHermes: 4d6bbb8c4832794c34fc2a0301a885a9e8c936d5
React-runtimescheduler: bb1282886aa8ba594ff5704c14ba19af1551149f
React-timing: 9b94f0fb713587a697ce56b0fc7cb31cb5be70a5
React-utils: 9e73840482020d1914b68089e807b3f2f56b10a3
ReactAppDependencyProvider: 3d947e9d62f351c06c71497e1be897e6006dc303
ReactCodegen: e92a1659b32705bd8ee0d2ba016d6993a4ade05b
ReactCommon: a02340b2a1a76f3703298a4680bb03277ca87440
RNAppleAuthentication: d6fe579e5f43cf8db54bdc48518bccea61c592a4
RNCAsyncStorage: 481acf401089f312189e100815088ea5dafc583c
RNCClipboard: 4d8c76e488f1491e5235901b7028ff53a678bd94
RNDeviceInfo: 53f9c84e28e854a308d3e548e25ef120b4615531
RNFBApp: b67ded6e4b0a6c0fee5e4f8e75e3a31567949a08
RNFBMessaging: 7202ad4c49bdcdc7fd3634d85827a6305049c148
RNFBRemoteConfig: d3b7942bc4a2e16e162b841c0df2a869015d98c9
RNGestureHandler: 75a1894590b15c560094c2b09c5dce6a64eefa29
RNGoogleSignin: bd5e55072fc89c69e3eb139be2a9c8935d0a0f2c
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
RNKeychain: 774184659ed098fd715a4976d44e2003c829934f
RNLocalize: 37ca6516cd717a04a8d85a17f9a3879a728ce179
RNReactNativeHapticFeedback: a49e613d48d721c99cad9689a490554104c22154
RNScreens: cc97e4382039563c725394067185356352df69ad
RNSentry: f343c58d33eb8351a5b5cfbb157d3527e2f59645
RNSVG: 2b1b9e597b2a0847e2963aefe17d976d5c882f3f
segment-analytics-react-native: 05c3bf2adb8a3be2c273808a6fdaced06d927917
React-utils: 07c3365e9dcbb8940e912ce099b20fb0e56dbacf
ReactAppDependencyProvider: 6e8d68583f39dc31ee65235110287277eb8556ef
ReactCodegen: 58a974a1a86362975fd49596480c5f0f17ee06a2
ReactCommon: e686c5766f0ebe5293be5a3957b833645cdac8ad
RNAppleAuthentication: a89c9804592b38ed4ab11f0aee68d05ba12ad432
RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce
RNCClipboard: 9f7b908de4bf4353871fb454c15fc03db4917b88
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
RNFBApp: 4105e54d9ca4a1c10893a032268470f670181110
RNFBMessaging: 6857871d9dff8f26b0c325fc7d97ba69cb77d213
RNFBRemoteConfig: 8d3675f18c052483ce294bb97b857428467fb41e
RNGestureHandler: 36aca36e4ef19f55dbf97239199d00fd58494e34
RNGoogleSignin: 60c3f470558dbff0ae54f2f164ef82a89d3eb561
RNInAppBrowser: 6d3eb68d471b9834335c664704719b8be1bfdb20
RNKeychain: 35beaa17938f7d8e4990d8a38fad5f8a748fc47c
RNLocalize: 67cd0eece3ba20fb5dae7625d77f02e88d3d9573
RNReactNativeHapticFeedback: eb5395b503c7a8f10de5e6722ef8afd3c61bc4f5
RNScreens: b0811b109e1a0b8b579f3348018e177bee374840
RNSentry: 98ab9f6a16c9596e36565ccf1a5871323f334766
RNSVG: d926926b169d8b81eb06aeb69734076e1dd566a3
SdkReactNative: 7f65ca10a978bf9440730a537d54511e68ed50b4
segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7
SelfNFCPassportReader: 8b53f9d483e0dd1f1a275953e3dc6dfc733694c5
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
sovran-react-native: eec37f82e4429f0e3661f46aaf4fcd85d1b54f60
sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594
SwiftQRScanner: e85a25f9b843e9231dab89a96e441472fe54a724
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
Yoga: c34725819ab0a5962e85455b9e56679b306910ee
PODFILE CHECKSUM: b55b83b47bd348c6768b89bdab74c5d2e9e09320
PODFILE CHECKSUM: 0b99fae2ec87b0be3e6d9b3fd87360fe0a84b25f
COCOAPODS: 1.16.2

View File

@@ -4,7 +4,7 @@ import Foundation
#if !E2E_TESTING
import Mixpanel
import NFCPassportReader
import SelfNFCPassportReader
public class SelfAnalytics: Analytics {
private let enableDebugLogs: Bool

View File

@@ -0,0 +1,31 @@
Pod::Spec.new do |s|
s.name = 'DiditSDK'
s.version = '3.2.6'
s.summary = 'Didit Identity Verification SDK for iOS'
s.description = <<-DESC
The Didit SDK provides a complete identity verification solution including
document scanning, NFC passport reading, face verification, and more.
This local podspec excludes the vendored OpenSSL.xcframework to avoid
conflicts with OpenSSL-Universal (provided by NFCPassportReader).
DESC
s.homepage = 'https://github.com/didit-protocol/sdk-ios'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = { 'Didit' => 'support@didit.me' }
s.source = {
:http => 'https://github.com/didit-protocol/sdk-ios/releases/download/3.2.6/DiditSDK-CocoaPods.zip'
}
s.ios.deployment_target = '13.0'
s.swift_version = '5.0'
# Remove the vendored OpenSSL.xcframework after download to avoid conflicts
# with OpenSSL-Universal (provided by NFCPassportReader)
s.prepare_command = 'rm -rf OpenSSL.xcframework'
s.vendored_frameworks = 'DiditSDK.xcframework'
s.dependency 'OpenSSL-Universal', '~> 1.1.1900'
s.frameworks = 'UIKit', 'SwiftUI', 'AVFoundation', 'CoreNFC', 'CoreLocation'
end

View File

@@ -0,0 +1,19 @@
Pod::Spec.new do |s|
s.name = 'OpenSSL-Universal'
s.version = '1.1.1900'
s.summary = 'Stub: OpenSSL is provided by DiditSDK vendored xcframework'
s.homepage = 'https://github.com/nicklama/openssl-universal'
s.license = { :type => 'Dual OpenSSL/SSLeay', :text => 'See OpenSSL license' }
s.author = 'stub'
s.source = { :git => '', :tag => s.version.to_s }
s.ios.deployment_target = '13.0'
s.preserve_paths = 'README.md'
# Point consumers to DiditSDK's vendored OpenSSL.xcframework so `import OpenSSL` resolves.
s.pod_target_xcconfig = {
'FRAMEWORK_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/DiditSDK"'
}
s.user_target_xcconfig = {
'FRAMEWORK_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/DiditSDK"'
}
end

View File

@@ -16,7 +16,7 @@ module.exports = {
'node',
],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@segment/analytics-react-native|@openpassport|react-native-keychain|react-native-check-version|react-native-nfc-manager|react-native-passport-reader|react-native-gesture-handler|uuid|@stablelib|@react-native-google-signin|react-native-cloud-storage|@react-native-clipboard|@react-native-firebase|@selfxyz|@sentry|@anon-aadhaar|react-native-svg|react-native-svg-circle-country-flags|react-native-blur-effect|@sumsub)/)',
'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@segment/analytics-react-native|@openpassport|react-native-keychain|react-native-check-version|react-native-nfc-manager|react-native-passport-reader|react-native-gesture-handler|uuid|@stablelib|@react-native-google-signin|react-native-cloud-storage|@react-native-clipboard|@react-native-firebase|@selfxyz|@sentry|@anon-aadhaar|react-native-svg|react-native-svg-circle-country-flags|react-native-blur-effect|@didit-protocol)/)',
],
setupFiles: ['<rootDir>/jest.setup.js'],
testMatch: [

View File

@@ -1284,28 +1284,15 @@ jest.mock('react-native/Libraries/AppState/AppState', () => {
};
});
// Mock @sumsub/react-native-mobilesdk-module
jest.mock('@sumsub/react-native-mobilesdk-module', () => {
const createBuilder = () => ({
withHandlers: jest.fn().mockReturnThis(),
withDebug: jest.fn().mockReturnThis(),
withLocale: jest.fn().mockReturnThis(),
withAnalyticsEnabled: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue({
launch: jest.fn().mockResolvedValue({ success: true }),
}),
});
const MockSNSMobileSDK = {
init: jest
.fn()
.mockImplementation((accessToken, tokenExpirationHandler) =>
createBuilder(),
),
};
return {
__esModule: true,
default: MockSNSMobileSDK,
};
});
// Mock @didit-protocol/sdk-react-native
jest.mock('@didit-protocol/sdk-react-native', () => ({
__esModule: true,
startVerification: jest.fn().mockResolvedValue({
type: 'completed',
session: { status: 'approved', sessionId: 'mock-session-id' },
}),
startVerificationWithWorkflow: jest.fn().mockResolvedValue({
type: 'completed',
session: { status: 'approved', sessionId: 'mock-session-id' },
}),
}));

View File

@@ -86,6 +86,7 @@
},
"dependencies": {
"@babel/runtime": "^7.28.6",
"@didit-protocol/sdk-react-native": "^3.2.7",
"@ethersproject/shims": "^5.8.0",
"@invertase/react-native-apple-authentication": "^2.5.1",
"@noble/hashes": "^1.5.0",
@@ -110,7 +111,6 @@
"@sentry/react": "^9.32.0",
"@sentry/react-native": "7.0.0",
"@stablelib/cbor": "^2.0.1",
"@sumsub/react-native-mobilesdk-module": "1.40.2",
"@tamagui/animations-css": "1.126.14",
"@tamagui/animations-react-native": "1.126.14",
"@tamagui/config": "1.126.14",

View File

@@ -6,9 +6,9 @@ const dependencies = {
'@selfxyz/mobile-sdk-alpha': { platforms: { android: null, ios: null } },
};
// Disable Sumsub SDK autolinking during E2E testing to avoid build issues
// Disable Didit SDK autolinking during E2E testing to avoid build issues
if (process.env.E2E_TESTING === '1') {
dependencies['@sumsub/react-native-mobilesdk-module'] = {
dependencies['@didit-protocol/sdk-react-native'] = {
platforms: { android: null, ios: null },
};
}

View File

@@ -29,7 +29,7 @@ interface KycIdCardProps {
/**
* Maps KYC idType to display title.
* idType values from Sumsub: "drivers_licence", "passport", "NATIONAL ID", etc.
* idType values: "drivers_licence", "passport", "NATIONAL ID", etc.
*/
function getKycDocTitle(idType: string): string {
const normalized = idType
@@ -45,7 +45,7 @@ function getKycDocTitle(idType: string): string {
/**
* KYC document card - matches IdCard design exactly but shows "STANDARD" badge.
* Used for documents verified through Sumsub KYC flow (drivers license, etc.).
* Used for documents verified through KYC flow (drivers license, etc.).
*/
const KycIdCard: FC<KycIdCardProps> = ({
idDocument,

View File

@@ -8,27 +8,30 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { sanitizeErrorMessage } from '@selfxyz/mobile-sdk-alpha';
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
import type { SumsubResult } from '@/integrations/sumsub/types';
import { createSession, launchDidit } from '@/integrations/didit';
import type { DiditVerificationResult } from '@/integrations/didit/types';
import type { RootStackParamList } from '@/navigation';
export type FallbackErrorSource = 'mrz_scan_failed' | 'nfc_scan_failed';
export interface UseSumsubLauncherOptions {
export interface UseDiditLauncherOptions {
/**
* Country code for the user's document
*/
countryCode: string;
/**
* Error source to track where the Sumsub launch was initiated from
* Error source to track where the Didit launch was initiated from
*/
errorSource: FallbackErrorSource;
/**
* Optional callback to handle successful verification.
* Receives the Sumsub result and the userId from the access token.
* If not provided, defaults to navigating to KycSuccess with the userId.
* Receives the Didit result and the sessionId from the session.
* If not provided, defaults to navigating to KycSuccess with the sessionId.
*/
onSuccess?: (result: SumsubResult, userId: string) => void | Promise<void>;
onSuccess?: (
result: DiditVerificationResult,
sessionId: string,
) => void | Promise<void>;
/**
* Optional callback to handle user cancellation
*/
@@ -36,53 +39,57 @@ export interface UseSumsubLauncherOptions {
/**
* Optional callback to handle verification failure
*/
onError?: (error: unknown, result?: SumsubResult) => void | Promise<void>;
onError?: (
error: unknown,
result?: DiditVerificationResult,
) => void | Promise<void>;
}
/**
* Custom hook for launching Sumsub verification with consistent error handling.
* Custom hook for launching Didit verification with consistent error handling.
*
* Abstracts the common pattern of:
* 1. Fetching access token
* 2. Launching Sumsub SDK
* 1. Creating a session
* 2. Launching Didit SDK
* 3. Handling errors by navigating to fallback screen
* 4. Managing loading state
*
* @example
* ```tsx
* const { launchSumsubVerification, isLoading } = useSumsubLauncher({
* const { launchDiditVerification, isLoading } = useDiditLauncher({
* countryCode: 'US',
* errorSource: 'nfc_scan_failed',
* });
*
* <Button onPress={launchSumsubVerification} disabled={isLoading}>
* <Button onPress={launchDiditVerification} disabled={isLoading}>
* {isLoading ? 'Loading...' : 'Try Alternative Verification'}
* </Button>
* ```
*/
export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
export const useDiditLauncher = (options: UseDiditLauncherOptions) => {
const { countryCode, errorSource, onSuccess, onCancel, onError } = options;
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [isLoading, setIsLoading] = useState(false);
const launchSumsubVerification = useCallback(async () => {
const launchDiditVerification = useCallback(async () => {
setIsLoading(true);
try {
const accessToken = await fetchAccessToken();
const result = await launchSumsub({ accessToken: accessToken.token });
const session = await createSession();
const result = await launchDidit(session.sessionToken);
// Handle user cancellation
if (!result.success && result.status === 'Interrupted') {
if (result.type === 'cancelled') {
await onCancel?.();
return;
}
// Handle verification failure
if (!result.success) {
const error = result.errorMsg || result.errorType || 'Unknown error';
if (result.type === 'failed') {
const error =
result.error?.message || result.error?.type || 'Unknown error';
const safeError = sanitizeErrorMessage(error);
console.error('Sumsub verification failed:', safeError);
console.error('Didit verification failed:', safeError);
// Call custom error handler if provided, otherwise navigate to fallback screen
if (onError) {
@@ -100,9 +107,9 @@ export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
// Handle success - navigate to KycSuccess by default
if (onSuccess) {
await onSuccess(result, accessToken.userId);
await onSuccess(result, session.sessionId);
} else {
navigation.navigate('KycSuccess', { userId: accessToken.userId });
navigation.navigate('KycSuccess', { sessionId: session.sessionId });
}
} catch (error) {
const errorMessage =
@@ -127,7 +134,7 @@ export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
}, [navigation, countryCode, errorSource, onSuccess, onCancel, onError]);
return {
launchSumsubVerification,
launchDiditVerification,
isLoading,
};
};

View File

@@ -4,17 +4,17 @@
import { useCallback, useRef } from 'react';
import { io, type Socket } from 'socket.io-client';
import { SUMSUB_TEE_URL } from '@env';
import { DIDIT_TEE_URL } from '@env';
import { deserializeApplicantInfo } from '@selfxyz/common';
import type { DocumentType, KycData } from '@selfxyz/common/utils/types';
import type { SumsubApplicantInfoSerialized } from '@/integrations/sumsub/types';
import type { ApplicantInfoSerialized } from '@/integrations/didit/types';
import { navigationRef } from '@/navigation';
import { storeDocumentWithDeduplication } from '@/providers/passportDataProvider';
import { usePendingKycStore } from '@/stores/pendingKycStore';
interface UseSumsubWebSocketOptions {
interface UseDiditWebSocketOptions {
onSuccess?: () => void;
onError?: (error: string) => void;
onVerificationFailed?: (reason: string) => void;
@@ -22,11 +22,11 @@ interface UseSumsubWebSocketOptions {
}
/**
* Shared hook for Sumsub websocket subscription logic.
* Handles connecting to the TEE service, subscribing to a userId,
* Shared hook for Didit websocket subscription logic.
* Handles connecting to the TEE service, subscribing to a sessionId,
* and processing verification results.
*/
export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
const {
onSuccess,
onError,
@@ -45,55 +45,58 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
);
const socketsRef = useRef<Map<string, Socket>>(new Map());
const subscribedUserIdsRef = useRef<Set<string>>(new Set());
const subscribedSessionIdsRef = useRef<Set<string>>(new Set());
const subscribe = useCallback(
(userId: string) => {
if (subscribedUserIdsRef.current.has(userId)) {
console.log('[SumsubWebSocket] Already subscribed to userId:', userId);
(sessionId: string) => {
if (subscribedSessionIdsRef.current.has(sessionId)) {
console.log(
'[DiditWebSocket] Already subscribed to sessionId:',
sessionId,
);
return;
}
const existingVerification = getPendingVerification(userId);
const existingVerification = getPendingVerification(sessionId);
const isProcessing = existingVerification?.status === 'processing';
// Don't retry 'processing' verifications as the proving machine is reading to be triggered.
if (isProcessing) {
console.log(
'[SumsubWebSocket] Verification in processing state, skipping for userId:',
userId,
'[DiditWebSocket] Verification in processing state, skipping for sessionId:',
sessionId,
);
return;
}
if (!skipAddPending) {
console.log(
'[SumsubWebSocket] Adding pending verification for userId:',
userId,
'[DiditWebSocket] Adding pending verification for sessionId:',
sessionId,
);
addPendingVerification(userId);
addPendingVerification(sessionId);
}
subscribedUserIdsRef.current.add(userId);
subscribedSessionIdsRef.current.add(sessionId);
console.log('[SumsubWebSocket] Connecting to WebSocket:', SUMSUB_TEE_URL);
const socket = io(SUMSUB_TEE_URL, {
console.log('[DiditWebSocket] Connecting to WebSocket:', DIDIT_TEE_URL);
const socket = io(DIDIT_TEE_URL, {
transports: ['websocket', 'polling'],
});
socketsRef.current.set(userId, socket);
socketsRef.current.set(sessionId, socket);
socket.on('connect', () => {
console.log(
'[SumsubWebSocket] Connected, subscribing to user:',
userId,
'[DiditWebSocket] Connected, subscribing to user:',
sessionId,
);
socket.emit('subscribe', userId);
socket.emit('subscribe', sessionId);
});
socket.on('success', async (data: SumsubApplicantInfoSerialized) => {
socket.on('success', async (data: ApplicantInfoSerialized) => {
console.log(
'[SumsubWebSocket] Received applicant info for userId:',
userId,
'[DiditWebSocket] Received applicant info for sessionId:',
sessionId,
);
try {
@@ -110,21 +113,27 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
};
const documentId = await storeDocumentWithDeduplication(kycData);
console.log(
'[SumsubWebSocket] KYC data stored successfully, documentId:',
'[DiditWebSocket] KYC data stored successfully, documentId:',
documentId,
);
updateVerificationStatus(userId, 'processing', undefined, documentId);
updateVerificationStatus(
sessionId,
'processing',
undefined,
documentId,
);
if (navigationRef.isReady()) {
navigationRef.navigate('KYCVerified', { documentId });
}
socket.emit('ack_success', sessionId);
onSuccess?.();
} catch (err) {
console.error('[SumsubWebSocket] Failed to store KYC data:', err);
console.error('[DiditWebSocket] Failed to store KYC data:', err);
updateVerificationStatus(
userId,
sessionId,
'failed',
'Failed to store KYC data',
);
@@ -132,32 +141,32 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
}
socket.disconnect();
socketsRef.current.delete(userId);
subscribedUserIdsRef.current.delete(userId);
socketsRef.current.delete(sessionId);
subscribedSessionIdsRef.current.delete(sessionId);
});
socket.on('verification_failed', (reason: string) => {
console.log('[SumsubWebSocket] Verification failed:', reason);
updateVerificationStatus(userId, 'failed', reason);
console.log('[DiditWebSocket] Verification failed:', reason);
updateVerificationStatus(sessionId, 'failed', reason);
onVerificationFailed?.(reason);
socket.disconnect();
socketsRef.current.delete(userId);
subscribedUserIdsRef.current.delete(userId);
socketsRef.current.delete(sessionId);
subscribedSessionIdsRef.current.delete(sessionId);
});
socket.on('error', (errorMessage: string) => {
console.error('[SumsubWebSocket] Socket error:', errorMessage);
updateVerificationStatus(userId, 'failed', errorMessage);
console.error('[DiditWebSocket] Socket error:', errorMessage);
updateVerificationStatus(sessionId, 'failed', errorMessage);
onError?.(errorMessage);
socket.disconnect();
socketsRef.current.delete(userId);
subscribedUserIdsRef.current.delete(userId);
socketsRef.current.delete(sessionId);
subscribedSessionIdsRef.current.delete(sessionId);
});
socket.on('disconnect', () => {
console.log('[SumsubWebSocket] Disconnected for userId:', userId);
console.log('[DiditWebSocket] Disconnected for sessionId:', sessionId);
});
},
[
@@ -171,13 +180,13 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
],
);
const unsubscribe = useCallback((userId: string) => {
const socket = socketsRef.current.get(userId);
const unsubscribe = useCallback((sessionId: string) => {
const socket = socketsRef.current.get(sessionId);
if (socket) {
socket.disconnect();
socketsRef.current.delete(userId);
socketsRef.current.delete(sessionId);
}
subscribedUserIdsRef.current.delete(userId);
subscribedSessionIdsRef.current.delete(sessionId);
}, []);
const unsubscribeAll = useCallback(() => {
@@ -185,11 +194,11 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
socket.disconnect();
});
socketsRef.current.clear();
subscribedUserIdsRef.current.clear();
subscribedSessionIdsRef.current.clear();
}, []);
const isSubscribed = useCallback((userId: string) => {
return subscribedUserIdsRef.current.has(userId);
const isSubscribed = useCallback((sessionId: string) => {
return subscribedSessionIdsRef.current.has(sessionId);
}, []);
return {

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useSumsubWebSocket } from '@/hooks/useSumsubWebSocket';
import { useDiditWebSocket } from '@/hooks/useDiditWebSocket';
import { navigationRef } from '@/navigation';
import { usePendingKycStore } from '@/stores/pendingKycStore';
@@ -14,9 +14,9 @@ import { usePendingKycStore } from '@/stores/pendingKycStore';
* This hook runs on app startup and:
* 1. Checks for any pending verifications in the store
* 2. For each non-expired pending/processing verification, reconnects to websocket
* 3. Subscribes to the userId to receive any cached results
* 3. Subscribes to the sessionId to receive any cached results
* 4. Updates verification status based on server response
* 5. Initiates proving machine after document storage (handled in useSumsubWebSocket)
* 5. Initiates proving machine after document storage (handled in useDiditWebSocket)
*
* NOTE: This requires the TEE server to cache completed verification results
* so they can be retrieved when the app reopens.
@@ -39,7 +39,7 @@ export function usePendingKycRecovery() {
console.log('[PendingKycRecovery] Verification failed:', reason);
}, []);
const { subscribe, unsubscribeAll } = useSumsubWebSocket({
const { subscribe, unsubscribeAll } = useDiditWebSocket({
skipAddPending: true,
onSuccess: handleSuccess,
onError: handleError,
@@ -56,7 +56,7 @@ export function usePendingKycRecovery() {
useEffect(() => {
console.log(
'[PendingKycRecovery] Already attempted userIds:',
'[PendingKycRecovery] Already attempted sessionIds:',
Array.from(hasAttemptedRecoveryRef.current),
);
@@ -65,39 +65,39 @@ export function usePendingKycRecovery() {
v.status === 'processing' &&
v.documentId &&
v.timeoutAt > Date.now() &&
!hasAttemptedRecoveryRef.current.has(v.userId),
!hasAttemptedRecoveryRef.current.has(v.sessionId),
);
if (processingWithDocument) {
console.log(
'[PendingKycRecovery] Resuming processing verification, navigating to KYCVerified:',
processingWithDocument.userId,
processingWithDocument.sessionId,
);
if (navigationRef.isReady()) {
navigationRef.navigate('KYCVerified', {
documentId: processingWithDocument.documentId,
});
// Only mark as attempted after successful navigation
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
hasAttemptedRecoveryRef.current.add(processingWithDocument.sessionId);
return;
}
// Navigation not ready yet - poll until ready
console.log(
'[PendingKycRecovery] Navigation not ready, polling for readiness:',
processingWithDocument.userId,
processingWithDocument.sessionId,
);
const pollInterval = setInterval(() => {
if (navigationRef.isReady()) {
console.log(
'[PendingKycRecovery] Navigation ready, navigating for:',
processingWithDocument.userId,
processingWithDocument.sessionId,
);
navigationRef.navigate('KYCVerified', {
documentId: processingWithDocument.documentId,
});
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
hasAttemptedRecoveryRef.current.add(processingWithDocument.sessionId);
clearInterval(pollInterval);
}
}, 100); // Poll every 100ms
@@ -112,16 +112,16 @@ export function usePendingKycRecovery() {
v =>
v.status === 'pending' &&
v.timeoutAt > Date.now() &&
!hasAttemptedRecoveryRef.current.has(v.userId),
!hasAttemptedRecoveryRef.current.has(v.sessionId),
);
if (firstPending) {
hasAttemptedRecoveryRef.current.add(firstPending.userId);
hasAttemptedRecoveryRef.current.add(firstPending.sessionId);
console.log(
'[PendingKycRecovery] Recovering pending verification:',
firstPending.userId,
firstPending.sessionId,
);
subscribe(firstPending.userId);
subscribe(firstPending.sessionId);
}
}, [pendingVerifications, subscribe, unsubscribeAll]);
}

View File

@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { startVerification } from '@didit-protocol/sdk-react-native';
import { DIDIT_TEE_URL } from '@env';
import type {
DiditVerificationResult,
SessionResponse,
} from '@/integrations/didit/types';
export interface DiditConfig {
locale?: string;
debug?: boolean;
}
const FETCH_TIMEOUT_MS = 30000;
export const createSession = async (): Promise<SessionResponse> => {
const apiUrl = DIDIT_TEE_URL;
console.log('[Didit] createSession URL:', apiUrl);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(`${apiUrl}/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to create Didit session (HTTP ${response.status})`,
);
}
const body = await response.json();
if (typeof body === 'string') {
return JSON.parse(body) as SessionResponse;
}
return body as SessionResponse;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
`Request to Didit TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
);
}
throw new Error(`Failed to create Didit session: ${err.message}`);
}
throw new Error('Failed to create Didit session: Unknown error');
}
};
export const launchDidit = async (
sessionToken: string,
config?: DiditConfig,
): Promise<DiditVerificationResult> => {
const result = await startVerification(sessionToken, {
languageCode: config?.locale ?? 'en',
loggingEnabled: config?.debug ?? __DEV__,
});
return result as DiditVerificationResult;
};

View File

@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type {
ApplicantInfoSerialized,
DiditVerificationResult,
SessionResponse,
} from '@/integrations/didit/types';
export {
type DiditConfig,
createSession,
launchDidit,
} from '@/integrations/didit/diditService';

View File

@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export interface ApplicantInfoSerialized {
signature: string;
applicantInfo: string;
pubkey: Array<string>;
}
export interface DiditVerificationResult {
type: 'completed' | 'cancelled' | 'failed';
session?: {
status: string;
sessionId: string;
};
error?: {
type: string;
message: string;
};
}
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export interface SessionResponse {
sessionId: string;
sessionToken: string;
}

View File

@@ -1,14 +0,0 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type {
AccessTokenResponse,
SumsubApplicantInfo,
SumsubResult,
} from '@/integrations/sumsub/types';
export {
type SumsubConfig,
fetchAccessToken,
launchSumsub,
} from '@/integrations/sumsub/sumsubService';

View File

@@ -1,145 +0,0 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { SUMSUB_TEE_URL } from '@env';
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module';
import { alpha2ToAlpha3 } from '@selfxyz/common';
import type {
AccessTokenResponse,
SumsubResult,
} from '@/integrations/sumsub/types';
// Maps Self document type codes to Sumsub document types
type SelfDocumentType = 'p' | 'i';
type SumsubDocumentType = 'PASSPORT' | 'ID_CARD';
const DOCUMENT_TYPE_MAP: Record<SelfDocumentType, SumsubDocumentType> = {
p: 'PASSPORT',
i: 'ID_CARD',
};
export interface SumsubConfig {
accessToken: string;
locale?: string;
debug?: boolean;
/** Self document type code ('p' for passport, 'i' for ID card) */
documentType?: SelfDocumentType;
/** ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB') */
countryCode?: string;
onStatusChanged?: (prevStatus: string, newStatus: string) => void;
onEvent?: (eventType: string, payload: unknown) => void;
}
const FETCH_TIMEOUT_MS = 30000; // 30 seconds
export const fetchAccessToken = async (): Promise<AccessTokenResponse> => {
const apiUrl = SUMSUB_TEE_URL;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(`${apiUrl}/access-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to get Sumsub access token (HTTP ${response.status})`,
);
}
const body = await response.json();
// Handle both string and object responses
if (typeof body === 'string') {
return JSON.parse(body) as AccessTokenResponse;
}
return body as AccessTokenResponse;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
`Request to Sumsub TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
);
}
throw new Error(`Failed to get Sumsub access token: ${err.message}`);
}
throw new Error('Failed to get Sumsub access token: Unknown error');
}
};
export const launchSumsub = async (
config: SumsubConfig,
): Promise<SumsubResult> => {
let sdk = SNSMobileSDK.init(config.accessToken, async () => {
// Token refresh not implemented for test flow
throw new Error(
'Sumsub token expired - refresh not implemented for test flow',
);
})
.withHandlers({
onStatusChanged: event => {
console.log(`Sumsub status: ${event.prevStatus} => ${event.newStatus}`);
config.onStatusChanged?.(event.prevStatus, event.newStatus);
},
onLog: _event => {
// Log event received but don't log message (may contain PII)
console.log('[Sumsub] Log event received');
},
onEvent: event => {
// Only log event type, not full payload (may contain PII)
console.log(`Sumsub event: ${event.eventType}`);
config.onEvent?.(event.eventType, event.payload);
},
})
.withDebug(config.debug ?? __DEV__)
.withLocale(config.locale ?? 'en')
// Platform configuration:
// - Device Intelligence (Fisherman): Enabled on both iOS and Android
// * iOS: Configured via IDENSIC_WITH_FISHERMAN in Podfile
// * Android: Configured via idensic-mobile-sdk-fisherman in patch file
// * Privacy: iOS declares device ID collection in PrivacyInfo.xcprivacy
// * Privacy: Android should declare device fingerprinting in Google Play Data Safety
// - VideoIdent (live video calls): Disabled on both platforms for current release
// * iOS: Disabled in Podfile (avoids microphone permission requirements)
// * Android: Disabled in patch file (avoids FOREGROUND_SERVICE_MICROPHONE permission)
// * Note: VideoIdent will be re-enabled on both platforms in future release for liveness checks
.withAnalyticsEnabled(true); // Required for Device Intelligence to function
// Pre-select document type and country if provided
// This skips the document selection step in Sumsub
if (config.documentType && config.countryCode) {
const sumsubDocType = DOCUMENT_TYPE_MAP[config.documentType];
// Handle both 2-letter (US) and 3-letter (USA) country codes
// alpha2ToAlpha3 returns undefined for 3-letter codes, so use the original if conversion fails
const alpha3Country =
alpha2ToAlpha3(config.countryCode) ?? config.countryCode;
if (sumsubDocType && alpha3Country) {
console.log(
`[Sumsub] Pre-selecting document: ${sumsubDocType} from ${alpha3Country}`,
);
sdk = sdk.withPreferredDocumentDefinitions({
IDENTITY: {
idDocType: sumsubDocType,
country: alpha3Country,
},
});
}
}
return sdk.build().launch();
};

View File

@@ -1,46 +0,0 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export interface AccessTokenResponse {
token: string;
userId: string;
}
export interface SumsubApplicantInfo {
id: string;
createdAt: string;
key: string;
clientId: string;
inspectionId: string;
externalUserId: string;
info?: {
firstName?: string;
lastName?: string;
dob?: string;
country?: string;
phone?: string;
};
email?: string;
phone?: string;
review: {
reviewAnswer: string;
reviewResult: {
reviewAnswer: string;
};
};
type: string;
}
export interface SumsubApplicantInfoSerialized {
signature: string;
applicantInfo: string;
pubkey: Array<string>;
}
export interface SumsubResult {
success: boolean;
status: string;
errorType?: string;
errorMsg?: string;
}

View File

@@ -153,7 +153,7 @@ export type OnboardingRoutesParamList = {
Disclaimer: undefined;
KycSuccess:
| {
userId?: string;
sessionId?: string;
}
| undefined;
KYCVerified:

View File

@@ -23,7 +23,7 @@ import {
} from '@selfxyz/mobile-sdk-alpha';
import { logNFCEvent, logProofEvent } from '@/config/sentry';
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
import { createSession, launchDidit } from '@/integrations/didit';
import type { RootStackParamList } from '@/navigation';
import { navigationRef } from '@/navigation';
import {
@@ -316,7 +316,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
documentTypes: string[];
}) => {
currentCountryCode = countryCode;
// Store country code early so it's available for Sumsub fallback flows
// Store country code early so it's available for Didit fallback flows
useMRZStore.getState().update({ countryCode });
navigateIfReady('IDPicker', { countryCode, documentTypes });
},
@@ -346,33 +346,23 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
if (
useErrorInjectionStore
.getState()
.shouldTrigger('sumsub_initialization')
.shouldTrigger('didit_initialization')
) {
console.log('[DEV] Injecting Sumsub initialization error');
console.log('[DEV] Injecting Didit initialization error');
throw new Error(
'Injected Sumsub initialization error for testing',
'Injected Didit initialization error for testing',
);
}
const accessToken = await fetchAccessToken();
const result = await launchSumsub({
accessToken: accessToken.token,
});
const session = await createSession();
const result = await launchDidit(session.sessionToken);
console.log('[Sumsub] Result:', JSON.stringify(result));
console.log('[Didit] Result:', JSON.stringify(result));
// User cancelled/dismissed without completing verification
// Status values: 'Initial' (never started), 'Incomplete' (started but not finished),
// 'Interrupted' (explicitly cancelled)
const cancelledStatuses = [
'Initial',
'Incomplete',
'Interrupted',
];
if (cancelledStatuses.includes(result.status)) {
if (result.type === 'cancelled') {
console.log(
'[Sumsub] User cancelled or closed without completing, status:',
result.status,
'[Didit] User cancelled or closed without completing',
);
return;
}
@@ -380,15 +370,20 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
// Dev-only: Check for injected verification error
const shouldInjectVerificationError = useErrorInjectionStore
.getState()
.shouldTrigger('sumsub_verification');
.shouldTrigger('didit_verification');
// Actual error from provider
if (!result.success || shouldInjectVerificationError) {
if (
result.type === 'failed' ||
shouldInjectVerificationError
) {
if (shouldInjectVerificationError) {
console.log('[DEV] Injecting Sumsub verification error');
console.log('[DEV] Injecting Didit verification error');
} else {
const safeError = sanitizeErrorMessage(
result.errorMsg || result.errorType || 'unknown_error',
result.error?.message ||
result.error?.type ||
'unknown_error',
);
console.error('KYC provider failed:', safeError);
}
@@ -402,15 +397,15 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
return;
}
// User completed verification (status: 'Pending', 'Approved', etc.)
// User completed verification
// Navigate to KYC success screen
console.log(
'[Sumsub] Verification submitted, status:',
result.status,
'[Didit] Verification submitted, status:',
result.session?.status,
);
if (navigationRef.isReady()) {
navigationRef.navigate('KycSuccess', {
userId: accessToken.userId,
sessionId: session.sessionId,
});
}
} catch (error) {

View File

@@ -3,7 +3,7 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { YStack } from 'tamagui';
import type { StaticScreenProps } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
@@ -71,36 +71,46 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
// DISABLED FOR NOW: Turnkey functionality
// const [turnkeyPending, setTurnkeyPending] = useState(false);
const { showModal: showDisableModal } = useModal(
useMemo(
() => ({
titleText: 'Disable cloud backups',
bodyText:
'Are you sure you want to disable cloud backups, you may lose your recovery phrase.',
buttonText: 'I understand the risks',
onButtonPress: async () => {
try {
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLE_STARTED);
await loginWithBiometrics();
await disableBackup();
toggleCloudBackupEnabled();
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLED_DONE);
} finally {
setICloudPending(false);
}
},
onModalDismiss: () => {
setICloudPending(false);
},
}),
const showDisableModal = useCallback(() => {
Alert.alert(
'Disable cloud backups',
'Are you sure you want to disable cloud backups? You may lose your recovery phrase.',
[
loginWithBiometrics,
disableBackup,
toggleCloudBackupEnabled,
trackEvent,
{
text: 'Cancel',
style: 'cancel',
onPress: () => setICloudPending(false),
},
{
text: 'Disable',
style: 'destructive',
onPress: async () => {
try {
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLE_STARTED);
await loginWithBiometrics();
await disableBackup();
toggleCloudBackupEnabled();
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLED_DONE);
} catch (error) {
console.error('Failed to disable cloud backup', error);
Alert.alert(
'Error',
'Failed to disable cloud backups. Please try again.',
);
} finally {
setICloudPending(false);
}
},
},
],
),
);
{ cancelable: false },
);
}, [
loginWithBiometrics,
disableBackup,
toggleCloudBackupEnabled,
trackEvent,
]);
const { showModal: showNoRegisteredAccountModal } = useModal(
useMemo(

View File

@@ -27,7 +27,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import WarningIcon from '@/assets/images/warning.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { extraYPadding } from '@/utils/styleUtils';
@@ -70,18 +70,16 @@ const AadhaarUploadErrorScreen: React.FC = () => {
const errorType = route.params?.errorType || 'general';
const { title, description } = getErrorMessages(errorType);
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
{
countryCode: 'IND',
errorSource: 'mrz_scan_failed', // Use a compatible error source
onCancel: () => {
navigation.goBack();
},
onError: () => {
// Stay on this screen - user can try again
},
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
countryCode: 'IND',
errorSource: 'mrz_scan_failed', // Use a compatible error source
onCancel: () => {
navigation.goBack();
},
);
onError: () => {
// Stay on this screen - user can try again
},
});
const handleClose = useCallback(() => {
buttonTap();
@@ -95,8 +93,8 @@ const AadhaarUploadErrorScreen: React.FC = () => {
const handleTryAlternative = useCallback(async () => {
trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
await launchSumsubVerification();
}, [errorType, launchSumsubVerification, trackEvent]);
await launchDiditVerification();
}, [errorType, launchDiditVerification, trackEvent]);
return (
<YStack flex={1} backgroundColor={slate100}>

View File

@@ -16,8 +16,8 @@ import QrScan from '@/assets/icons/qr_scan.svg';
import Star from '@/assets/icons/star.svg';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flush as flushAnalytics } from '@/services/analytics';
@@ -54,7 +54,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
const { launchDiditVerification, isLoading } = useDiditLauncher({
countryCode,
errorSource: 'mrz_scan_failed',
});
@@ -88,7 +88,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
</Caption>
<SecondaryButton
onPress={launchSumsubVerification}
onPress={launchDiditVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}

View File

@@ -14,9 +14,9 @@ import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { selectionChange } from '@/integrations/haptics';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flushAllAnalytics } from '@/services/analytics';
@@ -61,7 +61,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
const { launchDiditVerification, isLoading } = useDiditLauncher({
countryCode,
errorSource: 'nfc_scan_failed',
});
@@ -96,7 +96,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
</SecondaryButton>
<SecondaryButton
onPress={launchSumsubVerification}
onPress={launchDiditVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}

View File

@@ -25,7 +25,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import WarningIcon from '@/assets/images/warning.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { extraYPadding } from '@/utils/styleUtils';
@@ -66,19 +66,17 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
const headerTitle = getHeaderTitle(documentType);
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
{
countryCode,
errorSource: 'mrz_scan_failed',
onCancel: () => {
navigation.goBack();
},
onError: (_error, _result) => {
// Stay on this screen - user can try again
// Error is already logged in the hook
},
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
countryCode,
errorSource: 'mrz_scan_failed',
onCancel: () => {
navigation.goBack();
},
);
onError: (_error, _result) => {
// Stay on this screen - user can try again
// Error is already logged in the hook
},
});
const handleClose = useCallback(() => {
buttonTap();
@@ -89,8 +87,8 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
errorSource: 'mrz_scan_failed',
});
await launchSumsubVerification();
}, [launchSumsubVerification, trackEvent]);
await launchDiditVerification();
}, [launchDiditVerification, trackEvent]);
const handleRetryOriginal = useCallback(() => {
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {

View File

@@ -26,7 +26,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import WarningIcon from '@/assets/images/warning.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { extraYPadding } from '@/utils/styleUtils';
@@ -67,19 +67,17 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
const headerTitle = getHeaderTitle(documentType);
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
{
countryCode,
errorSource: 'nfc_scan_failed',
onCancel: () => {
navigation.goBack();
},
onError: (_error, _result) => {
// Stay on this screen - user can try again
// Error is already logged in the hook
},
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
countryCode,
errorSource: 'nfc_scan_failed',
onCancel: () => {
navigation.goBack();
},
);
onError: (_error, _result) => {
// Stay on this screen - user can try again
// Error is already logged in the hook
},
});
const handleClose = useCallback(() => {
buttonTap();
@@ -95,8 +93,8 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
errorSource: 'nfc_scan_failed',
});
await launchSumsubVerification();
}, [launchSumsubVerification, trackEvent]);
await launchDiditVerification();
}, [launchDiditVerification, trackEvent]);
const handleRetryOriginal = useCallback(() => {
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {

View File

@@ -25,11 +25,8 @@ import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import EPassportLogo from '@/assets/icons/epassport_logo.svg';
import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { createSession, launchDidit } from '@/integrations/didit';
import { buttonTap } from '@/integrations/haptics';
import {
fetchAccessToken,
launchSumsub,
} from '@/integrations/sumsub/sumsubService';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { useFeedback } from '@/providers/feedbackProvider';
@@ -41,7 +38,7 @@ type LogoConfirmationScreenRouteProp = RouteProp<
const LogoConfirmationScreen: React.FC = () => {
const route = useRoute<LogoConfirmationScreenRouteProp>();
const { documentType, countryCode } = route.params;
const { countryCode } = route.params;
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { showModal } = useFeedback();
@@ -61,27 +58,19 @@ const LogoConfirmationScreen: React.FC = () => {
buttonText: 'Proceed with an external verifier',
onButtonPress: async () => {
try {
const accessToken = await fetchAccessToken();
const result = await launchSumsub({
accessToken: accessToken.token,
// Pre-select document type and country based on user's earlier selection
documentType: documentType as 'p' | 'i',
countryCode,
});
const session = await createSession();
const result = await launchDidit(session.sessionToken);
// User cancelled/dismissed without completing verification
if (
!result.success &&
['Initial', 'Incomplete', 'Interrupted'].includes(result.status)
) {
if (result.type === 'cancelled') {
return;
}
// Verification failed (provider error/rejection)
if (!result.success) {
if (result.type === 'failed') {
console.error(
'Sumsub verification failed:',
result.errorType ?? result.status,
'Didit verification failed:',
result.error?.type ?? 'unknown',
);
navigation.navigate('KycFailure', {
countryCode,
@@ -91,9 +80,9 @@ const LogoConfirmationScreen: React.FC = () => {
}
// Verification succeeded - navigate to KycSuccessScreen
navigation.navigate('KycSuccess', { userId: accessToken.userId });
navigation.navigate('KycSuccess', { sessionId: session.sessionId });
} catch {
console.error('Error launching Sumsub verification');
console.error('Error launching Didit verification');
showModal({
titleText: 'Error',
bodyText: 'Unable to start verification. Please try again.',
@@ -103,7 +92,7 @@ const LogoConfirmationScreen: React.FC = () => {
}
},
});
}, [documentType, countryCode, navigation, showModal]);
}, [countryCode, navigation, showModal]);
return (
<ExpandableBottomLayout.Layout backgroundColor={slate100}>

View File

@@ -260,7 +260,7 @@ const HomeScreen: React.FC = () => {
{/* Show pending KYC cards at the top */}
{activePendingVerifications.map(verification => (
<PendingIdCard
key={verification.userId}
key={verification.sessionId}
onClick={() => {
if (
verification.status === 'processing' &&

View File

@@ -75,7 +75,7 @@ const KYCVerifiedScreen: React.FC = () => {
//TODO improvement: instead of removing it here, we could do it in provingMachine's final state(error/completed)
//if we do that, the card will still be displayed in Homescreen as 'Pending' if user click back midway during provingMachine
if (pendingVerification) {
removePendingVerification(pendingVerification.userId);
removePendingVerification(pendingVerification.sessionId);
}
const documentMetadata: {

View File

@@ -21,7 +21,7 @@ import {
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { useSumsubWebSocket } from '@/hooks/useSumsubWebSocket';
import { useDiditWebSocket } from '@/hooks/useDiditWebSocket';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import {
@@ -34,7 +34,7 @@ import { useSettingStore } from '@/stores/settingStore';
type KycSuccessRouteParams = StaticScreenProps<
| {
userId?: string;
sessionId?: string;
}
| undefined
>;
@@ -44,7 +44,7 @@ const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
}) => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const userId = params?.userId;
const sessionId = params?.sessionId;
const insets = useSafeAreaInsets();
const setFcmToken = useSettingStore(state => state.setFcmToken);
const selfClient = useSelfClient();
@@ -66,42 +66,42 @@ const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
console.log('[KycSuccessScreen] Verification failed:', reason);
}, []);
const { subscribe, unsubscribeAll } = useSumsubWebSocket({
const { subscribe, unsubscribeAll } = useDiditWebSocket({
onSuccess: handleWebSocketSuccess,
onError: handleWebSocketError,
onVerificationFailed: handleVerificationFailed,
});
useEffect(() => {
if (userId && !hasSubscribedRef.current) {
if (sessionId && !hasSubscribedRef.current) {
hasSubscribedRef.current = true;
console.log('[KycSuccessScreen] Subscribing to userId:', userId);
subscribe(userId);
console.log('[KycSuccessScreen] Subscribing to sessionId:', sessionId);
subscribe(sessionId);
}
return () => {
hasSubscribedRef.current = false;
unsubscribeAll();
};
}, [userId, subscribe, unsubscribeAll]);
}, [sessionId, subscribe, unsubscribeAll]);
const handleReceiveUpdates = useCallback(async () => {
buttonTap();
if ((await requestNotificationPermission()) && userId) {
if ((await requestNotificationPermission()) && sessionId) {
const token = await getFCMToken();
if (token) {
setFcmToken(token);
trackEvent(ProofEvents.FCM_TOKEN_STORED);
const sessionId = uuidv5(userId, SELF_UUID_NAMESPACE);
await registerDeviceToken(sessionId, token);
const notificationId = uuidv5(sessionId, SELF_UUID_NAMESPACE);
await registerDeviceToken(notificationId, token);
}
}
// Navigate to Home regardless of permission result
navigation.navigate('Home', {});
}, [navigation, setFcmToken, trackEvent, userId]);
}, [navigation, setFcmToken, trackEvent, sessionId]);
const handleCheckLater = () => {
buttonTap();

View File

@@ -16,8 +16,8 @@ export type InjectedErrorType =
| 'nfc_parse_failure'
| 'api_network_error'
| 'api_timeout'
| 'sumsub_initialization'
| 'sumsub_verification';
| 'didit_initialization'
| 'didit_verification';
export const ERROR_GROUPS = {
MRZ: ['mrz_invalid_format', 'mrz_unknown_error'] as InjectedErrorType[],
@@ -27,10 +27,7 @@ export const ERROR_GROUPS = {
'nfc_parse_failure',
] as InjectedErrorType[],
API: ['api_network_error', 'api_timeout'] as InjectedErrorType[],
Sumsub: [
'sumsub_initialization',
'sumsub_verification',
] as InjectedErrorType[],
Didit: ['didit_initialization', 'didit_verification'] as InjectedErrorType[],
};
export const ERROR_LABELS: Record<InjectedErrorType, string> = {
@@ -41,8 +38,8 @@ export const ERROR_LABELS: Record<InjectedErrorType, string> = {
nfc_parse_failure: 'NFC: Parse failure',
api_network_error: 'API: Network error',
api_timeout: 'API: Timeout',
sumsub_initialization: 'Sumsub: Initialization',
sumsub_verification: 'Sumsub: Verification',
didit_initialization: 'Didit: Initialization',
didit_verification: 'Didit: Verification',
};
interface ErrorInjectionState {

View File

@@ -16,19 +16,19 @@ const VERIFICATION_TIMEOUT_MS = 48 * 60 * 60 * 1000; // 48 hours TODO seshanth
interface PendingKycState {
pendingVerifications: PendingKycVerification[];
addPendingVerification: (userId: string) => void;
addPendingVerification: (sessionId: string) => void;
updateVerificationStatus: (
userId: string,
sessionId: string,
status: PendingKycStatus,
errorMessage?: string,
documentId?: string,
) => void;
removePendingVerification: (userId: string) => void;
removePendingVerification: (sessionId: string) => void;
removeExpiredVerifications: () => void;
clearAllPendingVerifications: () => void;
hasPendingVerification: () => boolean;
getPendingVerification: (
userId: string,
sessionId: string,
) => PendingKycVerification | undefined;
}
@@ -37,14 +37,16 @@ export const usePendingKycStore = create<PendingKycState>()(
(set, get) => ({
pendingVerifications: [],
addPendingVerification: (userId: string) => {
addPendingVerification: (sessionId: string) => {
const now = Date.now();
set(state => ({
pendingVerifications: [
// Remove any existing entry for this userId
...state.pendingVerifications.filter(v => v.userId !== userId),
// Remove any existing entry for this sessionId
...state.pendingVerifications.filter(
v => v.sessionId !== sessionId,
),
{
userId,
sessionId,
createdAt: now,
status: 'pending',
timeoutAt: now + VERIFICATION_TIMEOUT_MS,
@@ -54,14 +56,14 @@ export const usePendingKycStore = create<PendingKycState>()(
},
updateVerificationStatus: (
userId: string,
sessionId: string,
status: PendingKycStatus,
errorMessage?: string,
documentId?: string,
) => {
set(state => ({
pendingVerifications: state.pendingVerifications.map(v =>
v.userId === userId
v.sessionId === sessionId
? {
...v,
status,
@@ -73,10 +75,10 @@ export const usePendingKycStore = create<PendingKycState>()(
}));
},
removePendingVerification: (userId: string) => {
removePendingVerification: (sessionId: string) => {
set(state => ({
pendingVerifications: state.pendingVerifications.filter(
v => v.userId !== userId,
v => v.sessionId !== sessionId,
),
}));
},
@@ -97,11 +99,12 @@ export const usePendingKycStore = create<PendingKycState>()(
hasPendingVerification: () =>
get().pendingVerifications.some(v => v.status === 'pending'),
getPendingVerification: (userId: string) =>
get().pendingVerifications.find(v => v.userId === userId),
getPendingVerification: (sessionId: string) =>
get().pendingVerifications.find(v => v.sessionId === sessionId),
}),
{
name: 'pending-kyc-storage',
version: 1,
storage: createJSONStorage(() => AsyncStorage),
},
),

View File

@@ -1,56 +0,0 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
declare module '@sumsub/react-native-mobilesdk-module' {
export interface SumsubEvent {
eventType: string;
payload: Record<string, unknown>;
}
export interface SumsubHandlers {
onStatusChanged?: (event: SumsubStatusChangedEvent) => void;
onLog?: (event: SumsubLogEvent) => void;
onEvent?: (event: SumsubEvent) => void;
}
export interface SumsubLogEvent {
message: string;
}
export interface SumsubResult {
success: boolean;
status: string;
errorType?: string;
errorMsg?: string;
}
export interface SumsubSDK {
withHandlers(handlers: SumsubHandlers): SumsubSDK;
withDebug(debug: boolean): SumsubSDK;
withLocale(locale: string): SumsubSDK;
withAnalyticsEnabled(enabled: boolean): SumsubSDK;
withAutoCloseOnApprove(seconds: number): SumsubSDK;
withApplicantConf(config: { email?: string; phone?: string }): SumsubSDK;
withPreferredDocumentDefinitions(
definitions: Record<string, { idDocType: string; country: string }>,
): SumsubSDK;
withDisableMLKit(disable: boolean): SumsubSDK;
withStrings(strings: Record<string, string>): SumsubSDK;
build(): SumsubSDK;
launch(): Promise<SumsubResult>;
dismiss(): void;
}
export interface SumsubStatusChangedEvent {
prevStatus: string;
newStatus: string;
}
export default class SNSMobileSDK {
static init(
accessToken: string,
tokenExpirationHandler: () => Promise<string>,
): SumsubSDK;
}
}

View File

@@ -8,8 +8,8 @@ import { usePendingKycRecovery } from '@/hooks/usePendingKycRecovery';
import { navigationRef } from '@/navigation';
// Mock dependencies
jest.mock('@/hooks/useSumsubWebSocket', () => ({
useSumsubWebSocket: jest.fn(() => ({
jest.mock('@/hooks/useDiditWebSocket', () => ({
useDiditWebSocket: jest.fn(() => ({
subscribe: jest.fn(),
unsubscribeAll: jest.fn(),
})),
@@ -39,10 +39,8 @@ describe('usePendingKycRecovery', () => {
jest.useFakeTimers();
// Setup default mocks
const { useSumsubWebSocket } = jest.requireMock(
'@/hooks/useSumsubWebSocket',
);
useSumsubWebSocket.mockReturnValue({
const { useDiditWebSocket } = jest.requireMock('@/hooks/useDiditWebSocket');
useDiditWebSocket.mockReturnValue({
subscribe: mockSubscribe,
unsubscribeAll: mockUnsubscribeAll,
});
@@ -79,7 +77,7 @@ describe('usePendingKycRecovery', () => {
usePendingKycStore.mockReturnValue({
pendingVerifications: [
{
userId: 'user-123',
sessionId: 'session-123',
status: 'processing',
documentId: 'doc-456',
timeoutAt: Date.now() + 10000,
@@ -102,7 +100,7 @@ describe('usePendingKycRecovery', () => {
usePendingKycStore.mockReturnValue({
pendingVerifications: [
{
userId: 'user-123',
sessionId: 'session-123',
status: 'processing',
documentId: 'doc-456',
timeoutAt: Date.now() + 10000,
@@ -133,10 +131,10 @@ describe('usePendingKycRecovery', () => {
});
});
it('should not attempt recovery for same userId twice', () => {
it('should not attempt recovery for same sessionId twice', () => {
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
const verification = {
userId: 'user-123',
sessionId: 'session-123',
status: 'processing' as const,
documentId: 'doc-456',
timeoutAt: Date.now() + 10000,
@@ -156,7 +154,7 @@ describe('usePendingKycRecovery', () => {
// Rerender with same verification
rerender();
// Should not navigate again for same userId
// Should not navigate again for same sessionId
expect(mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
});
@@ -165,7 +163,7 @@ describe('usePendingKycRecovery', () => {
usePendingKycStore.mockReturnValue({
pendingVerifications: [
{
userId: 'user-789',
sessionId: 'session-789',
status: 'pending',
timeoutAt: Date.now() + 10000,
},
@@ -175,7 +173,7 @@ describe('usePendingKycRecovery', () => {
renderHook(() => usePendingKycRecovery());
expect(mockSubscribe).toHaveBeenCalledWith('user-789');
expect(mockSubscribe).toHaveBeenCalledWith('session-789');
});
it('should skip expired verifications', () => {
@@ -183,7 +181,7 @@ describe('usePendingKycRecovery', () => {
usePendingKycStore.mockReturnValue({
pendingVerifications: [
{
userId: 'user-expired',
sessionId: 'session-expired',
status: 'pending',
timeoutAt: Date.now() - 1000, // Expired
},
@@ -202,7 +200,7 @@ describe('usePendingKycRecovery', () => {
usePendingKycStore.mockReturnValue({
pendingVerifications: [
{
userId: 'user-123',
sessionId: 'session-123',
status: 'processing',
documentId: 'doc-456',
timeoutAt: Date.now() + 10000,
@@ -233,12 +231,12 @@ describe('usePendingKycRecovery', () => {
usePendingKycStore.mockReturnValue({
pendingVerifications: [
{
userId: 'user-pending',
sessionId: 'session-pending',
status: 'pending',
timeoutAt: Date.now() + 10000,
},
{
userId: 'user-processing',
sessionId: 'session-processing',
status: 'processing',
documentId: 'doc-789',
timeoutAt: Date.now() + 10000,

View File

@@ -24,23 +24,18 @@ jest.mock('@/services/analytics', () => ({
flush: jest.fn(),
}));
// Mock Sumsub SDK to prevent ES module parsing errors in isolateModules
jest.mock('@sumsub/react-native-mobilesdk-module', () => {
const createBuilder = () => ({
withHandlers: jest.fn().mockReturnThis(),
withDebug: jest.fn().mockReturnThis(),
withLocale: jest.fn().mockReturnThis(),
withAnalyticsEnabled: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue({
launch: jest.fn().mockResolvedValue({ success: true }),
}),
});
return {
__esModule: true,
default: { init: jest.fn(() => createBuilder()) },
};
});
// Mock Didit SDK to prevent ES module parsing errors in isolateModules
jest.mock('@didit-protocol/sdk-react-native', () => ({
__esModule: true,
startVerification: jest.fn().mockResolvedValue({
type: 'completed',
session: { status: 'approved', sessionId: 'mock-session-id' },
}),
startVerificationWithWorkflow: jest.fn().mockResolvedValue({
type: 'completed',
session: { status: 'approved', sessionId: 'mock-session-id' },
}),
}));
describe('navigation', () => {
beforeEach(() => {

View File

@@ -44,8 +44,8 @@ jest.mock('react-native-edge-to-edge', () => ({
SystemBars: () => null,
}));
jest.mock('@/hooks/useSumsubWebSocket', () => ({
useSumsubWebSocket: jest.fn(() => ({
jest.mock('@/hooks/useDiditWebSocket', () => ({
useDiditWebSocket: jest.fn(() => ({
subscribe: jest.fn(),
unsubscribe: jest.fn(),
unsubscribeAll: jest.fn(),
@@ -148,11 +148,11 @@ describe('KycSuccessScreen', () => {
const mockNavigate = jest.fn();
const mockTrackEvent = jest.fn();
const mockSetFcmToken = jest.fn();
const mockUserId = '19f21362-856a-4606-88e1-fa306036978f';
const mockSessionId = '19f21362-856a-4606-88e1-fa306036978f';
const mockFcmToken = 'mock-fcm-token';
const mockRoute = {
params: {
userId: mockUserId,
sessionId: mockSessionId,
},
};
@@ -227,7 +227,7 @@ describe('KycSuccessScreen', () => {
await waitFor(() => {
// Verify device token was registered with deterministic session ID
expect(notificationService.registerDeviceToken).toHaveBeenCalledWith(
uuidv5(mockUserId, notificationService.SELF_UUID_NAMESPACE),
uuidv5(mockSessionId, notificationService.SELF_UUID_NAMESPACE),
mockFcmToken,
);
});
@@ -290,7 +290,7 @@ describe('KycSuccessScreen', () => {
expect(notificationService.registerDeviceToken).not.toHaveBeenCalled();
});
it('should handle missing userId gracefully', async () => {
it('should handle missing sessionId gracefully', async () => {
const routeWithoutUserId = {
params: {},
};
@@ -312,7 +312,7 @@ describe('KycSuccessScreen', () => {
).toHaveBeenCalledTimes(1);
});
// Verify FCM token was NOT fetched (no userId)
// Verify FCM token was NOT fetched (no sessionId)
expect(notificationService.getFCMToken).not.toHaveBeenCalled();
await waitFor(() => {

View File

@@ -52,21 +52,15 @@ template REGISTER_KYC() {
id_num[i] <== data_padded[idNumberIdx + i];
}
signal nullifier_inputs[6 + id_number_length + id_type_length];
signal nullifier_inputs[id_number_length + id_type_length];
nullifier_inputs[0] <== 115; //s
nullifier_inputs[1] <== 117; //u
nullifier_inputs[2] <== 109; //m
nullifier_inputs[3] <== 115; //s
nullifier_inputs[4] <== 117; //u
nullifier_inputs[5] <== 98; //b
for (var i = 0; i < id_number_length; i++) {
nullifier_inputs[i + 6] <== id_num[i];
nullifier_inputs[i] <== id_num[i];
}
for (var i = 0; i < id_type_length; i++) {
nullifier_inputs[i + 6 + id_number_length] <== data_padded[id_type_index + i];
nullifier_inputs[i + id_number_length] <== data_padded[id_type_index + i];
}
signal output nullifier <== PackBytesAndPoseidon(6 + id_number_length + id_type_length)(nullifier_inputs);
signal output nullifier <== PackBytesAndPoseidon(id_number_length + id_type_length)(nullifier_inputs);
signal output commitment <== Poseidon(2)([secret, msg_hasher.out]);
signal output pubkey_hash <== Poseidon(2)([verifyIdCommSig.Ax, verifyIdCommSig.Ay]);

View File

@@ -3,7 +3,7 @@
# run from root
# first argument should register | dsc | disclose
if [[ $1 != "register" && $1 != "dsc" && $1 != "disclose" && $1 != "register_id" && $1 != "register_aadhaar" && $1 != "register_kyc" ]]; then
echo "first argument should be register | dsc | disclose | register_id | register_aadhaar"
echo "first argument should be register | dsc | disclose | register_id | register_aadhaar | register_kyc"
exit 1
fi

View File

@@ -2,8 +2,8 @@
# run from root
# first argument should register | dsc | disclose
if [[ $1 != "register" && $1 != "dsc" && $1 != "disclose" && $1 != "register_id" ]]; then
echo "first argument should be register | dsc | disclose | register_id"
if [[ $1 != "register" && $1 != "dsc" && $1 != "disclose" && $1 != "register_id" && $1 != "register_kyc" ]]; then
echo "first argument should be register | dsc | disclose | register_id | register_kyc"
exit 1
fi
@@ -67,6 +67,10 @@ REGISTER_ID_CIRCUITS=(
"register_id_sha512_sha512_sha512_rsapss_65537_64_2048:true"
)
REGISTER_KYC_CIRCUITS=(
"register_kyc:true"
)
DISCLOSE_CIRCUITS=(
"vc_and_disclose:true"
"vc_and_disclose_id:true"
@@ -105,6 +109,11 @@ elif [[ $1 == "register_id" ]]; then
output="output/register"
mkdir -p $output
basepath="./circuits/circuits/register_id/instances"
elif [[ $1 == "register_kyc" ]]; then
allowed_circuits=("${REGISTER_KYC_CIRCUITS[@]}")
output="output/register"
mkdir -p $output
basepath="./circuits/circuits/register/instances"
elif [[ $1 == "dsc" ]]; then
allowed_circuits=("${DSC_CIRCUITS[@]}")
output="output/dsc"
@@ -146,9 +155,9 @@ for item in "${allowed_circuits[@]}"; do
circuit_name="${filename%.*}"
(
circom $filepath \
-l "node_modules" \
-l "node_modules/@zk-kit/binary-merkle-root.circom/src" \
-l "node_modules/circomlib/circuits" \
-l "circuits/node_modules" \
-l "circuits/node_modules/@zk-kit/binary-merkle-root.circom/src" \
-l "circuits/node_modules/circomlib/circuits" \
--O1 --r1cs --wasm --output $output
) &
pids+=($!)

View File

@@ -51,7 +51,6 @@ describe('REGISTER KYC Circuit Tests', () => {
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
);
const nullifierInputs = [
...'sumsub'.split('').map((x) => x.charCodeAt(0)),
...idnumber,
...input.data_padded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
];

View File

@@ -41,9 +41,12 @@ export function deserializeApplicantInfo(
const country = applicantInfo
.slice(KYC_COUNTRY_INDEX, KYC_COUNTRY_INDEX + KYC_COUNTRY_LENGTH)
.replace(/\x00/g, '');
const idType = applicantInfo
.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH)
.replace(/\x00/g, '');
const idTypeRaw = applicantInfo.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH);
const nsLen = idTypeRaw.charCodeAt(0);
const idType =
nsLen > 0 && nsLen < KYC_ID_TYPE_LENGTH
? idTypeRaw.slice(1 + nsLen).replace(/\x00/g, '')
: idTypeRaw.replace(/\x00/g, '');
const idNumber = applicantInfo
.slice(KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH)
.replace(/\x00/g, '');

View File

@@ -2,21 +2,21 @@ import { poseidon2 } from 'poseidon-lite';
import { packBytesAndPoseidon } from '../hash.js';
import { IDDocument, isKycDocument } from '../types.js';
import { deserializeApplicantInfo } from './api.js';
import {
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_LENGTH,
KYC_ID_TYPE_INDEX,
KYC_ID_TYPE_LENGTH,
} from './constants.js';
import { serializeKycData } from './types.js';
const decodeRawBytes = (base64: string): number[] => {
const raw = Buffer.from(base64, 'base64');
return Array.from(raw, (b) => Number(b));
};
export const generateKycCommitment = (passportData: IDDocument, secret: string) => {
if (isKycDocument(passportData)) {
const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
const serializedData = serializeKycData(applicantInfo);
const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
const dataPadded = msgPadded.map((x) => Number(x));
const dataPadded = decodeRawBytes(passportData.serializedApplicantInfo);
const commitment = poseidon2([secret, packBytesAndPoseidon(dataPadded)]);
return commitment.toString();
}
@@ -24,16 +24,12 @@ export const generateKycCommitment = (passportData: IDDocument, secret: string)
export const generateKycNullifier = (passportData: IDDocument) => {
if (isKycDocument(passportData)) {
const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
const serializedData = serializeKycData(applicantInfo);
const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
const dataPadded = msgPadded.map((x) => Number(x));
const dataPadded = decodeRawBytes(passportData.serializedApplicantInfo);
const idNumber = dataPadded.slice(
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
);
const nullifierInputs = [
...'sumsub'.split('').map((x) => x.charCodeAt(0)),
...idNumber,
...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
];

View File

@@ -36,7 +36,6 @@ import {
KYC_ID_TYPE_INDEX,
KYC_ID_TYPE_LENGTH,
} from '../kyc/constants.js';
import { serializeKycData } from '../kyc/types.js';
import { sha384_512Pad, shaPad } from '../shaPad.js';
import { getLeafDscTree } from '../trees.js';
import type { DocumentCategory, IDDocument, PassportData, SignatureAlgorithm } from '../types.js';
@@ -209,16 +208,13 @@ export function generateNullifier(passportData: IDDocument) {
return nullifierHash(passportData.extractedFields);
}
if (isKycDocument(passportData)) {
const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
const serializedData = serializeKycData(applicantInfo);
const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
const dataPadded = msgPadded.map((x) => Number(x));
const raw = Buffer.from(passportData.serializedApplicantInfo, 'base64');
const dataPadded = Array.from(raw, (b) => Number(b));
const idNumber = dataPadded.slice(
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
);
const nullifierInputs = [
...'sumsub'.split('').map((x) => x.charCodeAt(0)),
...idNumber,
...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
];

View File

@@ -90,13 +90,13 @@ export interface PassportData extends BaseIDData {
passportMetadata?: PassportMetadata;
}
// pending - pending sumsub verification
// processing - sumsub verification completed and pending onchain confirmation
// failed - sumsub verification failed
// pending - pending didit verification
// processing - didit verification completed and pending onchain confirmation
// failed - didit verification failed
export type PendingKycStatus = 'pending' | 'processing' | 'failed';
export interface PendingKycVerification {
userId: string; // Correlation key from fetchAccessToken()
sessionId: string; // Correlation key from createSession()
createdAt: number; // Timestamp when verification started
status: PendingKycStatus; // Current status
errorMessage?: string; // Error message if failed

View File

@@ -0,0 +1,160 @@
# PR Audit — feat/euclid-settings-screens-rd2
> Date: 2026-03-23
> Branch: `feat/euclid-settings-screens-rd2`
> Base: `dev`
> PR: #1858
> Reviewers: Claude Code, Codex
> Status: Ship as-is with follow-up issues for known gaps
## Follow-up Issues
| Issue | Priority | Title |
| ------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------ |
| [SELF-2357](https://linear.app/selfprotocol/issue/SELF-2357) | Urgent | Euclid migration — complete euclid-web → euclid and validate API compatibility |
| [SELF-2358](https://linear.app/selfprotocol/issue/SELF-2358) | High | Sumsub / WV-05 contract compliance |
| [SELF-2359](https://linear.app/selfprotocol/issue/SELF-2359) | High | Tunnel + proving flow data propagation and UI contract fixes |
| [SELF-2360](https://linear.app/selfprotocol/issue/SELF-2360) | Medium | Settings persistence, test coverage, and doc/spec cleanup |
Each issue has a spec attached as a Linear document.
## Summary
This PR adds Euclid 3.0 settings sub-screens (Security, Notifications, Dev Mode), a PoC tunnel flow, the `euclid-web``euclid` migration, and a Sumsub Web SDK integration in the provider launch flow. It ships with known gaps that are tracked as follow-up issues.
## Merged Findings
Findings from both Claude Code and Codex reviews, deduplicated and grouped by follow-up issue.
### Issue 1: Euclid migration — complete `euclid-web` → `euclid` and validate exports
**Severity:** Critical (build-breaking)
- 3 settings screen components (`SecurityScreen`, `NotificationPreferencesScreen`, `DevModeScreen`) are imported from `@selfxyz/euclid` but not yet exported from the package
- `LaunchTour1Screen``LaunchTour4Screen` don't exist — Euclid only exports a single `TourScreen`
- `ProofGenerationScreen` doesn't exist — only a `ProofGeneration` component (not a screen wrapper)
- ~~`ProviderResultScreen` still imports from `@selfxyz/euclid-web`~~ — **fixed** (migrated to `@selfxyz/euclid`)
- ~~`ProviderLaunchScreen` still imports from `@selfxyz/euclid-web`~~ — **fixed** (migrated to `@selfxyz/euclid`, removed unused `BodyText`)
- Multiple screens migrated from `euclid-web` to `euclid` — blast radius is package-wide, not just settings
- `euclid-web` is being retired; all imports should use `@selfxyz/euclid`
- Settings handover doc (`docs/superpowers/plans/2026-03-22-settings-handover.md (legacy location)`) says the app still imports `euclid-web` — stale
**Acceptance criteria:**
- All imports use `@selfxyz/euclid`, zero references to `euclid-web`
- All imported screen components exist in Euclid's exports
- `yarn workspace @selfxyz/webview-app build` passes
### Issue 2: Sumsub / WV-05 contract compliance
**Severity:** High (incorrect behavior)
- `normalizeSumsubStatus()` marks `reviewAnswer === 'GREEN'` as `status: 'success'` without requiring `attestation` — breaks the KYC contract
- `onApplicantSubmitted` emits `status: 'partial'` through the `onComplete` callback (not `onError`), so `ProviderResultScreen` treats it as success and routes to `/proving`
- `emitOnce` guard means if `onApplicantSubmitted` fires before `applicantReviewComplete`, the actual terminal status is silently dropped
- `ProvingScreen` fabricates a successful `VerificationResult` without consuming any provider attestation payload
- `teeUrl` not parsed from launch URL/query params — `fetchSumsubAccessToken()` uses `VITE_SUMSUB_TEE_URL` or hardcoded default
- `fetchSumsubAccessToken` signature doesn't accept `teeUrl` as a parameter — even after parsing, plumbing is missing
- Token refresh callback in `launchSumsubWebSdk` (line 167) also calls `fetchSumsubAccessToken()` with no args — refresh hits hardcoded URL too
- `sumsub-websdk.d.ts` custom type declaration added — unclear if it matches actual SDK API or is a stub
- WV-05 plan says "code complete, needs testing" but `teeUrl` isn't parsed, service file structure wasn't created, and normalization doesn't match the contract
- Invalid step value: `TunnelProvingScreen` passes `step="generatingProof"` — valid values are `"registeringId" | "generatingProof1" | "awaitingVerification" | "finishingUp"`
**Acceptance criteria:**
- `normalizeSumsubStatus` requires attestation for `success`
- `partial` status blocked from entering proving flow
- `teeUrl` parsed from query params, threaded into token fetch and refresh
- WV-05 plan status downgraded to "partial implementation"
### Issue 3: Tunnel flow correctness and state propagation
**Severity:** High (data loss / broken UX)
- Selected ID type discarded in `TunnelIDTypeScreen.onIDTypeSelect` — ignores the param, always navigates to `/tunnel/proof/receipt`
- State passed via `location.state` is fragile — lost on refresh or direct navigation, no fallback or route guard
- `TunnelProofReceiptScreen` passes invalid `documentType="passport"` prop to `ProofRequestScreen` (not a valid prop)
- `TunnelProofReceiptScreen` mock items lack icons and `onInfoPress` callbacks
- `KycMockScreen` is raw HTML divs with inline styles instead of Euclid components
- `TunnelResultScreen` missing `animationSource` (Lottie) — uses icon only
- `Date.now()` in `TunnelProofReceiptScreen` creates new timestamp on every render
- Main proving flow (`ProvingScreen.tsx`) hardcodes `documentType='passport'` — affects primary flow, not just tunnel
**Acceptance criteria:**
- ID type selection persisted and forwarded through the tunnel chain
- Screens handle missing state gracefully (redirect or error)
- Correct Euclid component props used throughout
### Issue 4: Settings persistence and bridge-backed actions
**Severity:** Medium (non-functional features)
- `SecurityScreen` backup state is hardcoded `false` — should query actual state from bridge/storage
- `SecurityScreen` handlers for backup, recovery phrase, restore all navigate to `/coming-soon`
- `NotificationPreferencesScreen` toggles are local `useState` — lost on reload, never persisted
- `DevModeScreen` mock config is local state — lost on reload
- `DevModeScreen` "Generate mock document" fires analytics but doesn't create or store anything
- `SettingsScreen` "Manage Documents", "Get support", "Share Self" all navigate to `/coming-soon`
**Acceptance criteria:**
- Settings state backed by bridge/storage where applicable
- Placeholder routes documented as intentional
### Issue 5: Test coverage
**Severity:** Medium
- No tests for Sumsub normalization/result mapping (`normalizeSumsubStatus`, `buildProviderResult`)
- No route tests for tunnel flow progression
- No smoke tests for settings wrapper navigation and action wiring
- No tests for provider launch/result flow
**Acceptance criteria:**
- Unit tests for Sumsub normalization logic
- Route/navigation tests for tunnel flow
- Smoke tests for settings screens
### Issue 6: Doc/spec drift
**Severity:** Low
- Settings handover doc (`docs/superpowers/plans/2026-03-22-settings-handover.md (legacy location)`) is stale — references `euclid-web`, understates scope
- Settings integration plan (`docs/superpowers/plans/2026-03-22-settings-screen-integration.md (legacy location)`) scoped to settings only but branch includes tunnel/provider/migration
- WV-05 plan status overstated
- Orphaned route: `/onboarding/confirm` defined in App.tsx but never navigated to
**Acceptance criteria:**
- Docs reflect actual branch state
- WV-05 status corrected
- Orphaned route removed or documented
## Actionable PR feedback (CodeRabbit + Codex connector)
Extracted from PR #1858 inline comments. Items already covered above are not repeated.
### Build-breaking (addressed in this branch)
- **`@selfxyz/euclid-web` removed from `package.json` but still imported** — `ProviderLaunchScreen` and `ProviderResultScreen` still referenced it. Both migrated to `@selfxyz/euclid` in this branch. (CodeRabbit P1, Codex connector P1)
### Actionable (not yet addressed)
- **`TunnelIDTypeScreen`: pass selected `idType` to next screen** — `onIDTypeSelect` ignores the param. Suggested fix: `navigate('/tunnel/proof/receipt', { state: { documentType: idType.id } })`. (CodeRabbit, mapped to Issue 3)
- **`TunnelProofReceiptScreen`: read document type from route state** — Currently hardcodes `documentType="passport"`. Should read from `location.state` with fallback. (CodeRabbit, mapped to Issue 3)
- **`NotificationPreferencesScreen`: persist toggles** — Use `storage` adapter from `useSelfClient()` to save/load preferences. (CodeRabbit, mapped to Issue 4)
- **`DevModeScreen`: mock document generation is a no-op** — Should either persist the mock or show a toast indicating the feature isn't functional yet. (CodeRabbit, mapped to Issue 4)
### Nitpicks (low priority)
- **`NotificationPreferencesScreen`: memoize toggle handlers** — `toggles` array recreated every render. `useMemo` with `toggleValues` as dependency would be cleaner.
## What works well
- Route wiring is clean — every route has a file, every `navigate()` targets a defined route
- Settings menu restructure (App settings / Support & feedback / Developer tools) is well-organized
- Sumsub SDK lifecycle management (mount/destroy/abort) is properly handled
- Analytics events are consistently tracked across all screens
- `emitOnce` pattern prevents duplicate callbacks (though it has the silenced-review-status side effect)

View File

@@ -0,0 +1,102 @@
# Euclid 3.0 Settings Integration — Handover
**Branch:** `feat/euclid-settings-screens` (based off `origin/main`)
**Commit:** `feat(webview-app): add Euclid 3.0 settings sub-screens`
## What Was Done
Three new wrapper screens were added to `packages/webview-app/src/screens/account/`:
| File | Euclid Component | Route |
| ----------------------------------- | ------------------------------- | ------------------------- |
| `SecurityScreen.tsx` | `SecurityScreen` | `/settings/security` |
| `NotificationPreferencesScreen.tsx` | `NotificationPreferencesScreen` | `/settings/notifications` |
| `DevModeScreen.tsx` | `DevModeScreen` | `/settings/dev-mode` |
Routes added to `App.tsx`. `SettingsScreen.tsx` updated with three sections (App settings, Support & feedback, Developer tools) — "Security", "Notifications", and "Dev mode" now navigate to real screens instead of `/coming-soon`.
Each wrapper follows the existing pattern (see `CountryPickerScreen.tsx`): import Euclid component, wire with `useNavigate()` + `useSelfClient()` bridge adapters, manage local UI state.
## Blocker: Euclid Package Publish
The installed `@selfxyz/euclid-web@1.0.2` does **not** export `SecurityScreen`, `NotificationPreferencesScreen`, or `DevModeScreen`. These screens exist on `origin/main` of the **euclid repo** (`/Users/evinova-self/Documents/euclid`) but haven't been published to npm yet.
**To unblock, you need to publish a new version of `@selfxyz/euclid`** (note: package was renamed from `euclid-web` to `euclid` on euclid's `origin/main`).
Steps:
1. In the euclid repo, check out `origin/main` and verify the screens export: `git show origin/main:packages/euclid/src/screens/index.ts`
2. Bump the version and publish (the euclid repo has automated publishing via PR merge of version bump PRs)
3. In the self repo, update `packages/webview-app/package.json` to use the new version (and rename dependency from `@selfxyz/euclid-web` to `@selfxyz/euclid` if the package name changed)
4. Run `yarn install` to pull the new package
5. Run `yarn workspace @selfxyz/webview-app exec tsc --noEmit` to verify type-check passes
### Package Rename Note
The euclid repo renamed the web package from `@selfxyz/euclid-web` to `@selfxyz/euclid`. The webview-app still imports from `@selfxyz/euclid-web`. When updating the dependency, you'll also need to update all import paths across the webview-app screens:
```
- import { ... } from '@selfxyz/euclid-web';
+ import { ... } from '@selfxyz/euclid';
```
## Pre-Existing Type Errors
These errors exist on `origin/main` independent of our changes:
- `BridgeProvider.tsx``browserHost` not in `WebViewBridgeOptions`
- `SelfClientProvider.tsx``lifecycle.dismiss()` argument mismatch
- `ConfirmIdentificationScreen.tsx`, `ProvingScreen.tsx`, `VerificationResultScreen.tsx``VerificationResult` type mismatch
- `ProviderLaunchScreen.tsx``lifecycle.dismiss()` argument mismatch
## What Still Uses `/coming-soon`
These items in SettingsScreen still navigate to `/coming-soon` (no Euclid screen exists yet):
- **"Manage Documents"** — needs a document management screen
- **"Get support"** — needs external link / bridge intent
- **"Share Self"** — needs native share sheet via bridge
Within the sub-screens, these actions are also stubbed:
- **SecurityScreen**: "Backup your account", "Reveal recovery phrase", "Restore an account" → all go to `/coming-soon` (need native bridge integration for biometric auth, iCloud backup, wallet restore)
- **NotificationPreferencesScreen**: Toggle state is local-only (needs bridge storage persistence)
- **DevModeScreen**: "Generate mock document" tracks analytics and navigates home but doesn't actually create a mock document yet
## Navigation Flow
```
/settings (SettingsViewScreen)
├── Manage Documents → /coming-soon
├── Security → /settings/security ✅ NEW
│ ├── Backup your account → /coming-soon
│ ├── Reveal recovery phrase → /coming-soon
│ ├── Restore an account → /coming-soon
│ └── Disable backups → local dialogue toggle
├── Notifications → /settings/notifications ✅ NEW
│ └── Toggles → local state only
├── Get support → /coming-soon
├── Share Self → /coming-soon
├── Dev mode → /settings/dev-mode ✅ NEW
│ ├── Steppers/toggles → local state
│ ├── Generate mock document → analytics + navigate home
│ └── Reset all values → reset state
└── Close Self → lifecycle.dismiss()
```
## Validation
After unblocking the euclid dependency:
```bash
yarn workspace @selfxyz/webview-app exec tsc --noEmit # type-check
yarn workspace @selfxyz/webview-app build # Vite production build
yarn lint # lint
```
## Related Resources
- **Implementation plan:** `docs/superpowers/plans/2026-03-22-settings-screen-integration.md`
- **Euclid screen source:** `euclid repo origin/main:packages/euclid/src/screens/settings/`
- **Euclid storybook stories:** `euclid repo origin/main:packages/storybook/stories/*Screen.stories.tsx`
- **Linear tickets:** SELF-2223 (Settings), SELF-2311 (Update core app)

View File

@@ -0,0 +1,667 @@
# Settings Screen Integration — Import Euclid 3.0 Screens into WebView App
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the five `/coming-soon` navigations in the webview-app's SettingsScreen with real Euclid 3.0 sub-screens (Security, Notifications, Dev Mode), plus wire the remaining menu items to appropriate bridge actions.
**Architecture:** Each settings sub-screen gets a thin wrapper in `packages/webview-app/src/screens/account/` that imports the Euclid component from `@selfxyz/euclid-web`, wires it with `useSelfClient()` bridge adapters and `useNavigate()` from React Router, and manages local UI state (e.g. dialogue visibility, toggle values). Routes are added to `App.tsx`. The existing wrapper pattern (see `CountryPickerScreen.tsx`, `ComingSoonScreen.tsx`) is followed exactly.
**Tech Stack:** React, React Router, `@selfxyz/euclid-web` (Euclid 3.0 component library), `@selfxyz/webview-bridge` (bridge adapters)
**Existing pattern to follow:**
```
// webview-app wrapper screen pattern:
import { EuclidScreen } from '@selfxyz/euclid-web';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useNavigate } from 'react-router-dom';
export const WrapperScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic, lifecycle } = useSelfClient();
// Wire Euclid props to bridge adapters + React Router
return <EuclidScreen {...wiredProps} />;
};
```
---
## File Map
| Action | File | Responsibility |
| ------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Modify | `packages/webview-app/src/App.tsx` | Add 3 new routes |
| Modify | `packages/webview-app/src/screens/account/SettingsScreen.tsx` | Replace `/coming-soon` navigations with real routes + bridge actions |
| Create | `packages/webview-app/src/screens/account/SecurityScreen.tsx` | Wrapper for Euclid `SecurityScreen` |
| Create | `packages/webview-app/src/screens/account/NotificationPreferencesScreen.tsx` | Wrapper for Euclid `NotificationPreferencesScreen` |
| Create | `packages/webview-app/src/screens/account/DevModeScreen.tsx` | Wrapper for Euclid `DevModeScreen` |
---
## Task 1: SecurityScreen wrapper
The most important sub-screen. Wires backup state, recovery phrase, and restore actions through the bridge.
**Files:**
- Create: `packages/webview-app/src/screens/account/SecurityScreen.tsx`
- [ ] **Step 1: Create SecurityScreen wrapper**
```tsx
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
SecurityScreen as EuclidSecurityScreen,
LeftArrowIcon,
CloudKeyIcon,
LockIcon,
ZapShieldIcon,
} from '@selfxyz/euclid-web';
import { useSelfClient } from '../../providers/SelfClientProvider';
export const SecurityScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic, storage } = useSelfClient();
const [isBackupEnabled, setIsBackupEnabled] = useState(false);
const [showDisableDialogue, setShowDisableDialogue] = useState(false);
// TODO: Read actual backup state from bridge storage on mount
// useEffect(() => { storage.get('backup_enabled').then(...) }, []);
const onBack = useCallback(() => {
haptic.trigger('selection');
navigate('/settings');
}, [navigate, haptic]);
const onBackupAccount = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('security_backup_account_pressed');
navigate('/coming-soon');
}, [navigate, haptic, analytics]);
const onRevealRecoveryPhrase = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('security_reveal_phrase_pressed');
navigate('/coming-soon');
}, [navigate, haptic, analytics]);
const onRestoreAccount = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('security_restore_account_pressed');
navigate('/coming-soon');
}, [navigate, haptic, analytics]);
const onDisableBackups = useCallback(() => {
haptic.trigger('warning');
setShowDisableDialogue(true);
}, [haptic]);
const onDisableICloudBackups = useCallback(() => {
haptic.trigger('warning');
analytics.trackEvent('security_backups_disabled');
setIsBackupEnabled(false);
setShowDisableDialogue(false);
// TODO: Persist via bridge storage
}, [haptic, analytics]);
const onDismissDialogue = useCallback(() => {
haptic.trigger('selection');
setShowDisableDialogue(false);
}, [haptic]);
return (
<EuclidSecurityScreen
insets={{ top: 0, bottom: 0 }}
escapeIcon={({ size, color }) => (
<LeftArrowIcon size={size} color={color} />
)}
cloudKeyIcon={CloudKeyIcon}
lockIcon={LockIcon}
zapShieldIcon={ZapShieldIcon}
isBackupEnabled={isBackupEnabled}
onBack={onBack}
onBackupAccount={onBackupAccount}
onRevealRecoveryPhrase={onRevealRecoveryPhrase}
onRestoreAccount={onRestoreAccount}
onDisableBackups={onDisableBackups}
showDisableDialogue={showDisableDialogue}
onDisableICloudBackups={onDisableICloudBackups}
onDismissDialogue={onDismissDialogue}
/>
);
};
```
- [ ] **Step 2: Verify type-check passes**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
Expected: No errors related to SecurityScreen
- [ ] **Step 3: Commit**
```bash
git add packages/webview-app/src/screens/account/SecurityScreen.tsx
git commit -m "feat(webview-app): add SecurityScreen wrapper for Euclid 3.0"
```
---
## Task 2: NotificationPreferencesScreen wrapper
Manages toggle state locally (persisted via bridge storage in future).
**Files:**
- Create: `packages/webview-app/src/screens/account/NotificationPreferencesScreen.tsx`
- [ ] **Step 1: Create NotificationPreferencesScreen wrapper**
```tsx
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
NotificationPreferencesScreen as EuclidNotificationPreferencesScreen,
LeftArrowIcon,
} from '@selfxyz/euclid-web';
import { useSelfClient } from '../../providers/SelfClientProvider';
const defaultToggles = [
{
key: 'self',
label: 'Allow Self notifications',
description: 'App updates and more',
},
{
key: 'nova',
label: 'Allow Nova notifications',
description: 'Never miss a mission',
},
{
key: 'points',
label: 'Allow Self Points notifications',
description: 'Points and rewards',
},
{
key: 'id_status',
label: 'Allow ID status notifications',
description: 'Document verification updates',
},
];
export const NotificationPreferencesScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic } = useSelfClient();
const [toggleValues, setToggleValues] = useState<Record<string, boolean>>({
self: true,
nova: true,
points: true,
id_status: false,
});
const onBack = useCallback(() => {
haptic.trigger('selection');
navigate('/settings');
}, [navigate, haptic]);
const toggles = defaultToggles.map(t => ({
label: t.label,
description: t.description,
value: toggleValues[t.key] ?? false,
onToggleChange: (value: boolean) => {
haptic.trigger('selection');
analytics.trackEvent('notification_toggle_changed', {
key: t.key,
value,
});
setToggleValues(prev => ({ ...prev, [t.key]: value }));
},
}));
return (
<EuclidNotificationPreferencesScreen
insets={{ top: 0, bottom: 0 }}
escapeIcon={({ size, color }) => (
<LeftArrowIcon size={size} color={color} />
)}
onBack={onBack}
toggles={toggles}
/>
);
};
```
- [ ] **Step 2: Verify type-check passes**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
Expected: No errors related to NotificationPreferencesScreen
- [ ] **Step 3: Commit**
```bash
git add packages/webview-app/src/screens/account/NotificationPreferencesScreen.tsx
git commit -m "feat(webview-app): add NotificationPreferencesScreen wrapper for Euclid 3.0"
```
---
## Task 3: DevModeScreen wrapper
Manages mock document generation state. More complex — has steppers, dropdowns, toggle, and IDCard display.
**Files:**
- Create: `packages/webview-app/src/screens/account/DevModeScreen.tsx`
- [ ] **Step 1: Create DevModeScreen wrapper**
```tsx
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
DevModeScreen as EuclidDevModeScreen,
LeftArrowIcon,
} from '@selfxyz/euclid-web';
import type { IDCardProps } from '@selfxyz/euclid-web';
import { useSelfClient } from '../../providers/SelfClientProvider';
const ageOptions = ['18 or older', '21 or older', '25 or older', '30 or older'];
const expiryOptions = ['1 year', '2 years', '5 years', '10 years'];
export const DevModeScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic } = useSelfClient();
const [documentType, setDocumentType] = useState('passport');
const [nationality, setNationality] = useState('united states of america');
const [ageIndex, setAgeIndex] = useState(1);
const [expiryIndex, setExpiryIndex] = useState(2);
const [ofacCheck, setOfacCheck] = useState(true);
const idCard: IDCardProps = {
variant: 'dev-passport',
title: 'Developer Passport',
subtitle: 'Digital credential for developers',
};
const onBack = useCallback(() => {
haptic.trigger('selection');
navigate('/settings');
}, [navigate, haptic]);
const onResetAllValues = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('dev_mode_reset');
setDocumentType('passport');
setNationality('united states of america');
setAgeIndex(1);
setExpiryIndex(2);
setOfacCheck(true);
}, [haptic, analytics]);
const onGenerateMockDocument = useCallback(() => {
haptic.trigger('success');
analytics.trackEvent('dev_mode_generate_mock', {
documentType,
nationality,
age: ageOptions[ageIndex],
expiresIn: expiryOptions[expiryIndex],
ofacCheck,
});
navigate('/');
}, [
navigate,
haptic,
analytics,
documentType,
nationality,
ageIndex,
expiryIndex,
ofacCheck,
]);
return (
<EuclidDevModeScreen
insets={{ top: 0, bottom: 0 }}
escapeIcon={({ size, color }) => (
<LeftArrowIcon size={size} color={color} />
)}
onBack={onBack}
idCard={idCard}
documentType={documentType}
onDocumentTypePress={() => {
setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport'));
}}
nationality={nationality}
onNationalityPress={() => {
setNationality(prev =>
prev === 'united states of america'
? 'germany'
: 'united states of america',
);
}}
age={ageOptions[ageIndex]}
onAgeIncrement={() =>
setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))
}
onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))}
documentExpiresIn={expiryOptions[expiryIndex]}
onDocumentExpiresIncrement={() =>
setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))
}
onDocumentExpiresDecrement={() =>
setExpiryIndex(prev => Math.max(prev - 1, 0))
}
ofacCheck={ofacCheck}
onOfacCheckChange={value => {
haptic.trigger('selection');
setOfacCheck(value);
}}
onResetAllValues={onResetAllValues}
onGenerateMockDocument={onGenerateMockDocument}
/>
);
};
```
- [ ] **Step 2: Verify type-check passes**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
Expected: No errors related to DevModeScreen
- [ ] **Step 3: Commit**
```bash
git add packages/webview-app/src/screens/account/DevModeScreen.tsx
git commit -m "feat(webview-app): add DevModeScreen wrapper for Euclid 3.0"
```
---
## Task 4: Wire routes in App.tsx
Add the three new routes under `/settings/*`.
**Files:**
- Modify: `packages/webview-app/src/App.tsx`
- [ ] **Step 1: Add imports and routes**
Add these imports after the existing `SettingsScreen` import (line 16):
```tsx
import { SecurityScreen } from './screens/account/SecurityScreen';
import { NotificationPreferencesScreen } from './screens/account/NotificationPreferencesScreen';
import { DevModeScreen } from './screens/account/DevModeScreen';
```
Add these routes after the `/settings` route (after line 34):
```tsx
<Route path="/settings/security" element={<SecurityScreen />} />
<Route path="/settings/notifications" element={<NotificationPreferencesScreen />} />
<Route path="/settings/dev-mode" element={<DevModeScreen />} />
```
- [ ] **Step 2: Verify type-check passes**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add packages/webview-app/src/App.tsx
git commit -m "feat(webview-app): add settings sub-screen routes"
```
---
## Task 5: Wire SettingsScreen menu items to real routes
Replace the five `/coming-soon` navigations with real routes and bridge actions.
**Files:**
- Modify: `packages/webview-app/src/screens/account/SettingsScreen.tsx`
- [ ] **Step 1: Update SettingsScreen menu items**
Replace the full component with updated navigation wiring. Key changes:
- "View document info" → `navigate('/coming-soon')` (no Euclid screen for this yet — keep as-is)
- "Recovery phrase" → `navigate('/settings/security')` (Security screen handles this)
- "Cloud backup" → `navigate('/settings/security')` (Security screen handles this)
- "Get support" → call `lifecycle.dismiss()` with support intent (or keep `/coming-soon`)
- "Share Self" → call `lifecycle.dismiss()` with share intent (or keep `/coming-soon`)
Additionally, add the Settings sub-sections that match the Euclid Storybook stories:
- **App settings section**: Manage Documents, Security, Notifications
- **Support section**: Support, Send feedback
- **Developer tools section** (conditional): Dev mode
```tsx
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
SettingsViewScreen,
LeftArrowIcon,
QuestionCircleStrokeIcon,
DocumentDetailsIcon,
LockIcon,
NotificationIcon,
ChatStrokeIcon,
ShareIcon,
CodeIcon,
} from '@selfxyz/euclid-web';
import { useSelfClient } from '../../providers/SelfClientProvider';
export const SettingsScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic, lifecycle } = useSelfClient();
const onBack = useCallback(() => {
haptic.trigger('selection');
navigate('/');
}, [navigate, haptic]);
const onDismiss = useCallback(async () => {
haptic.trigger('selection');
analytics.trackEvent('settings_dismiss_pressed');
lifecycle.dismiss();
}, [haptic, analytics, lifecycle]);
return (
<SettingsViewScreen
insets={{ top: 0, bottom: 0 }}
escapeIcon={({ size, color }) => (
<LeftArrowIcon size={size} color={color} />
)}
infoIcon={({ size, color }) => (
<QuestionCircleStrokeIcon size={size} color={color} />
)}
onClose={onBack}
showBackupInfoBox={false}
isBackupEnabled={false}
CTAs={[]}
sections={[
{
title: 'App settings',
items: [
{
icon: DocumentDetailsIcon,
label: 'Manage Documents',
description: 'Recovery phrase, passport data',
onPress: () => {
haptic.trigger('selection');
analytics.trackEvent('settings_manage_documents_pressed');
navigate('/coming-soon');
},
},
{
icon: LockIcon,
label: 'Security',
description: 'Recovery phrase, passport data',
onPress: () => {
haptic.trigger('selection');
analytics.trackEvent('settings_security_pressed');
navigate('/settings/security');
},
},
{
icon: NotificationIcon,
label: 'Notifications',
description: 'Preferences, notification types',
onPress: () => {
haptic.trigger('selection');
analytics.trackEvent('settings_notifications_pressed');
navigate('/settings/notifications');
},
},
],
},
{
title: 'Support & feedback',
items: [
{
icon: ChatStrokeIcon,
label: 'Get support',
description: 'Help center & support',
onPress: () => {
haptic.trigger('selection');
analytics.trackEvent('settings_support_pressed');
navigate('/coming-soon');
},
},
{
icon: ShareIcon,
label: 'Share Self',
description: 'Share Self with friends',
onPress: () => {
haptic.trigger('selection');
analytics.trackEvent('settings_share_pressed');
navigate('/coming-soon');
},
},
],
},
{
title: 'Developer tools',
items: [
{
icon: CodeIcon,
label: 'Dev mode',
description: 'Manage mock IDs, simulate proofs',
onPress: () => {
haptic.trigger('selection');
analytics.trackEvent('settings_dev_mode_pressed');
navigate('/settings/dev-mode');
},
},
],
},
]}
connectHeading=""
connectSubheading=""
connectButtons={[]}
bottomSectionItems={[
{
label: 'Close Self',
onPress: onDismiss,
},
]}
/>
);
};
```
- [ ] **Step 2: Verify type-check passes**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
Expected: PASS
- [ ] **Step 3: Verify Vite build succeeds**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app build`
Expected: Build succeeds, bundle output in `dist/`
- [ ] **Step 4: Commit**
```bash
git add packages/webview-app/src/screens/account/SettingsScreen.tsx
git commit -m "feat(webview-app): wire settings menu to Security, Notifications, and DevMode screens"
```
---
## Task 6: Final validation
- [ ] **Step 1: Full type-check**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
Expected: PASS with zero errors
- [ ] **Step 2: Vite production build**
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app build`
Expected: Build succeeds. Bundle size should be similar to before (~294 KB, may increase slightly with 3 new screens)
- [ ] **Step 3: Lint check**
Run: `cd /Users/evinova-self/Documents/self && yarn lint`
Expected: No new lint errors in changed files
---
## Navigation Flow After Implementation
```
/settings (SettingsViewScreen)
├─ "Manage Documents" → /coming-soon (no screen yet)
├─ "Security" → /settings/security (SecurityScreen)
│ ├─ "Backup your account" → /coming-soon (future: backup flow)
│ ├─ "Reveal recovery phrase" → /coming-soon (future: bridge to native)
│ ├─ "Restore an account" → /coming-soon (future: restore flow)
│ └─ "Disable backups" → shows dialogue → local state toggle
├─ "Notifications" → /settings/notifications (NotificationPreferencesScreen)
│ └─ Toggle changes → local state (future: bridge to native prefs)
├─ "Get support" → /coming-soon (future: external link)
├─ "Share Self" → /coming-soon (future: share sheet via bridge)
├─ "Dev mode" → /settings/dev-mode (DevModeScreen)
│ ├─ Steppers/toggles → local state
│ ├─ "Generate mock document" → analytics event + navigate home
│ └─ "Reset all values" → reset local state
└─ "Close Self" → lifecycle.dismiss()
```
## Out of Scope
- Persisting toggle/backup state via bridge storage (marked with `// TODO` comments)
- Recovery phrase reveal flow (requires biometric auth via bridge)
- Cloud backup flow (requires native iCloud/Google backup APIs)
- Restore account flow (requires native wallet restore)
- Support link / Share sheet (requires native intents via bridge)
- Manage Documents screen (no Euclid screen exists yet)
- `@selfxyz/euclid-web``@selfxyz/euclid` package rename (separate dependency update task)

View File

@@ -36,9 +36,12 @@ export function deserializeApplicantInfo(
const country = applicantInfo
.slice(KYC_COUNTRY_INDEX, KYC_COUNTRY_INDEX + KYC_COUNTRY_LENGTH)
.replace(/\x00/g, '');
const idType = applicantInfo
.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH)
.replace(/\x00/g, '');
const idTypeRaw = applicantInfo.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH);
const nsLen = idTypeRaw.charCodeAt(0);
const idType =
nsLen > 0 && nsLen < KYC_ID_TYPE_LENGTH
? idTypeRaw.slice(1 + nsLen).replace(/\x00/g, '')
: idTypeRaw.replace(/\x00/g, '');
const idNumber = applicantInfo
.slice(KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH)
.replace(/\x00/g, '');

View File

@@ -2,35 +2,31 @@ import { poseidon2 } from 'poseidon-lite';
import { packBytesAndPoseidon } from '../../crypto/hash/poseidon.js';
import type { KycData as KycDocumentData } from '../../foundation/types/document.js';
import { deserializeApplicantInfo } from './api.js';
import {
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_LENGTH,
KYC_ID_TYPE_INDEX,
KYC_ID_TYPE_LENGTH,
} from './constants.js';
import { serializeKycData } from './types.js';
const decodeRawBytes = (base64: string): number[] => {
const raw = Buffer.from(base64, 'base64');
return Array.from(raw, b => Number(b));
};
export const generateKycCommitment = (kycData: KycDocumentData, secret: string) => {
const applicantInfo = deserializeApplicantInfo(kycData.serializedApplicantInfo);
const serializedData = serializeKycData(applicantInfo);
const msgPadded = Array.from(serializedData, x => x.charCodeAt(0));
const dataPadded = msgPadded.map(x => Number(x));
const dataPadded = decodeRawBytes(kycData.serializedApplicantInfo);
const commitment = poseidon2([secret, packBytesAndPoseidon(dataPadded)]);
return commitment.toString();
};
export const generateKycNullifier = (kycData: KycDocumentData) => {
const applicantInfo = deserializeApplicantInfo(kycData.serializedApplicantInfo);
const serializedData = serializeKycData(applicantInfo);
const msgPadded = Array.from(serializedData, x => x.charCodeAt(0));
const dataPadded = msgPadded.map(x => Number(x));
const dataPadded = decodeRawBytes(kycData.serializedApplicantInfo);
const idNumber = dataPadded.slice(
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH,
);
const nullifierInputs = [
...'sumsub'.split('').map(x => x.charCodeAt(0)),
...idNumber,
...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
];

View File

@@ -18,6 +18,9 @@
"build": "yarn workspaces foreach --topological-dev --parallel --exclude @selfxyz/contracts --exclude @selfxyz/circuits --exclude mobile-sdk-demo --exclude @selfxyz/kmp-sdk --exclude @selfxyz/kmp-sdk-test-app -i --all run build",
"build:demo": "yarn workspace mobile-sdk-demo build",
"build:mobile-sdk": "yarn workspace @selfxyz/mobile-sdk-alpha build",
"build:sdk-android": "./scripts/build-webview-bundle.sh && cd packages/native-shell-android && ./gradlew assembleRelease",
"build:sdk-bundle": "./scripts/build-webview-bundle.sh",
"build:sdk-ios": "./scripts/build-webview-bundle.sh && cd packages/native-shell-ios && swift build",
"check:versions": "node scripts/check-package-versions.mjs",
"demo:mobile": "yarn build:mobile-sdk && yarn build:demo && yarn workspace mobile-sdk-demo start",
"docstrings": "yarn docstrings:app && yarn docstrings:sdk",

View File

@@ -142,14 +142,24 @@ yarn types # Verify type checking
yarn build # Confirm build still works
```
## SDK Architecture Specs
## Linear Issue Interaction
For architecture context, implementation details, and workstream coordination:
When working with Linear issues during development:
- **[SDK Overview](../../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix
- **[SDK Core Spec](../../specs/projects/sdk/workstreams/sdk-core/SPEC.md)** — Implementation chunks for this package (mobile-sdk-alpha)
- **`save_comment`** for: status updates, progress notes, blockers, linking PRs, corrections, decision records
- **`save_issue`** for: changing status, priority, assignee, labels (structured fields only)
- **`create_document`** for: attaching specs as Linear documents
Before implementing SDK work, read `CLAUDE.md` Key Rules and `specs/projects/sdk/workstreams/sdk-core/SPEC.md` for constraints and validation commands.
**Never overwrite an issue description.** Descriptions are the original scope set at creation time. All subsequent context goes in comments.
## SDK Architecture
For architecture context:
- **[SDK Overview](../../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix (read-only reference)
- **Implementation specs** — Canonical source is `specs/projects/sdk/workstreams/<scope>/plans/` (version-controlled). Linear documents attached to issues are mirrored copies for tracking/discovery. When in doubt, trust the repo spec.
Before implementing SDK work, read `CLAUDE.md` Key Rules for constraints and validation commands.
## Notes

View File

@@ -7,3 +7,4 @@ export { createIndexedDBDocumentsAdapter } from './documents';
export { createNoOpHapticAdapter } from './haptic';
export { createWebAnalyticsAdapter } from './analytics';
export { createWebCryptoAdapter } from './crypto';
export { createWebNetworkAdapter } from './network';

View File

@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { NetworkAdapter, WsConn } from '../../types/public';
/**
* Creates a {@link NetworkAdapter} backed by the platform's native `fetch` and
* `WebSocket` globals. Works in any browser, WebView, or React Native context
* where those globals are available.
*/
export function createWebNetworkAdapter(): NetworkAdapter {
return {
http: {
fetch: (input: RequestInfo, init?: RequestInit) => fetch(input, init),
},
ws: {
connect: (url: string): WsConn => {
const socket = new WebSocket(url);
return {
send: (data: string | ArrayBufferView | ArrayBuffer) => socket.send(data),
close: () => socket.close(),
onMessage: cb => {
socket.addEventListener('message', ev => cb((ev as MessageEvent).data));
},
onError: cb => {
socket.addEventListener('error', e => cb(e));
},
onClose: cb => {
socket.addEventListener('close', () => cb());
},
};
},
},
};
}

View File

@@ -12,8 +12,10 @@ export type {
ClockAdapter,
Config,
CryptoAdapter,
DocumentCatalog,
DocumentsAdapter,
HttpAdapter,
IDDocument,
LogLevel,
LoggerAdapter,
MRZInfo,
@@ -37,10 +39,9 @@ export type {
export type { BaseContext, NFCScanContext, ProofContext } from './proving/internal/logging';
export type { DG1, DG2, ParsedNFCResponse } from './nfc';
export type { PassportValidationCallbacks } from './validation/document';
export type { ProvingStateType, provingMachineCircuitType } from './proving/provingMachine';
export type { ProvingState, ProvingStateType, provingMachineCircuitType } from './proving/provingMachine';
export type { SDKEvent, SDKEventMap } from './types/events';
export type { SdkErrorCategory } from './errors';
export type { WebAnalyticsOptions } from './adapters/browser';
export {
@@ -76,6 +77,7 @@ export {
createNoOpHapticAdapter,
createWebAnalyticsAdapter,
createWebCryptoAdapter,
createWebNetworkAdapter,
} from './adapters/browser';
export { createListenersMap, createSelfClient } from './client';
@@ -87,6 +89,8 @@ export { extractMRZInfo, extractNameFromMRZ, formatDateToYYMMDD } from './mrz';
export { generateMockDocument, signatureAlgorithmToStrictSignatureAlgorithm } from './mock/generator';
export { getPostVerificationRoute, useProvingStore } from './proving/provingMachine';
export { isPassportDataValid } from './validation/document';
export { mergeConfig } from './config/merge';

View File

@@ -0,0 +1,62 @@
plugins {
id("com.android.library") version "8.2.2"
id("org.jetbrains.kotlin.android") version "1.9.22"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22"
}
group = "xyz.self.sdk"
android {
namespace = "xyz.self.sdk.nativeshell"
compileSdk = 34
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
tasks.register("validateWebViewBundle") {
doLast {
val bundleDir = file("src/main/assets/self-wallet")
val indexFile = file("src/main/assets/self-wallet/index.html")
if (!bundleDir.exists() || !indexFile.exists()) {
throw GradleException(
"WebView bundle not found at src/main/assets/self-wallet/index.html. " +
"Run ./scripts/build-webview-bundle.sh from the repo root first."
)
}
}
}
tasks.named("preBuild") {
dependsOn("validateWebViewBundle")
}
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.webkit:webkit:1.9.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

View File

@@ -0,0 +1,4 @@
# Consumer ProGuard rules for native-shell-android
# Keep bridge models for JSON serialization
-keep class xyz.self.sdk.bridge.** { *; }
-keep class xyz.self.sdk.api.** { *; }

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
networkTimeout=600000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

247
packages/native-shell-android/gradlew vendored Executable file
View File

@@ -0,0 +1,247 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1 @@
# ProGuard rules for native-shell-android

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "native-shell-android"

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="xyz.self.sdk.webview.SelfVerificationActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:configChanges="orientation|screenSize|keyboardHidden" />
</application>
</manifest>

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.api
import android.app.Activity
import android.content.Intent
import xyz.self.sdk.webview.SelfVerificationActivity
object SelfSdk {
fun launch(activity: Activity, config: SelfSdkConfig, requestCode: Int = REQUEST_CODE_VERIFICATION) {
val intent = Intent(activity, SelfVerificationActivity::class.java).apply {
putExtra(SelfVerificationActivity.EXTRA_TEE_URL, config.teeUrl)
putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_ID, config.verificationId)
putExtra(SelfVerificationActivity.EXTRA_USER_ID, config.userId)
putExtra(SelfVerificationActivity.EXTRA_DEBUG_MODE, config.isDebugMode)
}
activity.startActivityForResult(intent, requestCode)
}
fun handleResult(
requestCode: Int,
resultCode: Int,
data: Intent?,
callback: SelfSdkCallback,
expectedRequestCode: Int = REQUEST_CODE_VERIFICATION,
) {
if (requestCode != expectedRequestCode) return
when (resultCode) {
Activity.RESULT_OK -> {
val resultData = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_DATA) ?: "{}"
callback.onSuccess(resultData)
}
Activity.RESULT_CANCELED -> {
callback.onCancelled()
}
Activity.RESULT_FIRST_USER -> {
val resultData = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_DATA)
callback.onFailure(SelfSdkException(resultData ?: "Verification failed"))
}
else -> {
callback.onFailure(SelfSdkException("Verification failed with result code: $resultCode"))
}
}
}
const val REQUEST_CODE_VERIFICATION = 9001
}

View File

@@ -0,0 +1,18 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.api
data class SelfSdkConfig(
val teeUrl: String,
val verificationId: String,
val userId: String,
val isDebugMode: Boolean = false,
)
class SelfSdkException(message: String) : Exception(message)
interface SelfSdkCallback {
fun onSuccess(resultJson: String)
fun onFailure(error: SelfSdkException)
fun onCancelled()
}

View File

@@ -0,0 +1,21 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.bridge
import kotlinx.serialization.json.JsonElement
interface BridgeHandler {
val domain: BridgeDomain
suspend fun handle(
method: String,
params: Map<String, JsonElement>,
): JsonElement?
}
class BridgeHandlerException(
val code: String,
override val message: String,
val details: Map<String, JsonElement>? = null,
cause: Throwable? = null,
) : Exception(message, cause)

View File

@@ -0,0 +1,65 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.bridge
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
const val BRIDGE_PROTOCOL_VERSION = 1
@Serializable
enum class BridgeDomain {
@SerialName("nfc") NFC,
@SerialName("biometrics") BIOMETRICS,
@SerialName("secureStorage") SECURE_STORAGE,
@SerialName("camera") CAMERA,
@SerialName("crypto") CRYPTO,
@SerialName("haptic") HAPTIC,
@SerialName("analytics") ANALYTICS,
@SerialName("lifecycle") LIFECYCLE,
@SerialName("documents") DOCUMENTS,
@SerialName("navigation") NAVIGATION,
}
@Serializable
data class BridgeError(
val code: String,
val message: String,
val details: Map<String, JsonElement>? = null,
)
@Serializable
data class BridgeRequest(
val type: String = "request",
val version: Int,
val id: String,
val domain: BridgeDomain,
val method: String,
val params: Map<String, JsonElement> = emptyMap(),
val timestamp: Long,
)
@Serializable
data class BridgeResponse(
val type: String = "response",
val version: Int = BRIDGE_PROTOCOL_VERSION,
val id: String,
val domain: BridgeDomain,
val requestId: String,
val success: Boolean,
val data: JsonElement? = null,
val error: BridgeError? = null,
val timestamp: Long = System.currentTimeMillis(),
)
@Serializable
data class BridgeEvent(
val type: String = "event",
val version: Int = BRIDGE_PROTOCOL_VERSION,
val id: String,
val domain: BridgeDomain,
val event: String,
val data: JsonElement,
val timestamp: Long = System.currentTimeMillis(),
)

View File

@@ -0,0 +1,139 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.bridge
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import java.util.UUID
class MessageRouter(
private val sendToWebView: (js: String) -> Unit,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private val handlers = mutableMapOf<BridgeDomain, BridgeHandler>()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
fun register(handler: BridgeHandler) {
handlers[handler.domain] = handler
}
fun onMessageReceived(rawJson: String) {
val request = try {
json.decodeFromString<BridgeRequest>(rawJson)
} catch (e: Exception) {
android.util.Log.e("BridgeRouter", "Failed to decode request: ${e::class.simpleName}")
return
}
android.util.Log.d("BridgeRouter", "Received: domain=${request.domain} method=${request.method}")
if (request.version != BRIDGE_PROTOCOL_VERSION) {
sendResponse(
BridgeResponse(
id = UUID.randomUUID().toString(),
domain = request.domain,
requestId = request.id,
success = false,
error = BridgeError(
code = "UNSUPPORTED_VERSION",
message = "Protocol version ${request.version} is not supported",
),
),
)
return
}
val handler = handlers[request.domain]
if (handler == null) {
sendResponse(
BridgeResponse(
id = UUID.randomUUID().toString(),
domain = request.domain,
requestId = request.id,
success = false,
error = BridgeError(
code = "DOMAIN_NOT_FOUND",
message = "No handler registered for domain: ${request.domain}",
),
),
)
return
}
scope.launch {
try {
val result = handler.handle(request.method, request.params)
sendResponse(
BridgeResponse(
id = UUID.randomUUID().toString(),
domain = request.domain,
requestId = request.id,
success = true,
data = result,
),
)
} catch (e: BridgeHandlerException) {
sendResponse(
BridgeResponse(
id = UUID.randomUUID().toString(),
domain = request.domain,
requestId = request.id,
success = false,
error = BridgeError(
code = e.code,
message = e.message,
details = e.details,
),
),
)
} catch (e: Exception) {
sendResponse(
BridgeResponse(
id = UUID.randomUUID().toString(),
domain = request.domain,
requestId = request.id,
success = false,
error = BridgeError(
code = "INTERNAL_ERROR",
message = e.message ?: "Unknown error",
),
),
)
}
}
}
fun pushEvent(domain: BridgeDomain, event: String, data: JsonElement) {
val bridgeEvent = BridgeEvent(
id = UUID.randomUUID().toString(),
domain = domain,
event = event,
data = data,
)
val eventJson = json.encodeToString(bridgeEvent)
sendToWebView("window.SelfNativeBridge._handleEvent(${escapeForJs(eventJson)})")
}
private fun sendResponse(response: BridgeResponse) {
val responseJson = json.encodeToString(response)
android.util.Log.d("BridgeRouter", "Sending response: domain=${response.domain} success=${response.success}")
sendToWebView("window.SelfNativeBridge._handleResponse(${escapeForJs(responseJson)})")
}
companion object {
fun escapeForJs(jsonStr: String): String {
val escaped = jsonStr
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
return "'$escaped'"
}
}
}

View File

@@ -0,0 +1,96 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.handlers
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import xyz.self.sdk.bridge.BridgeDomain
import xyz.self.sdk.bridge.BridgeHandler
import xyz.self.sdk.bridge.BridgeHandlerException
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
class CryptoHandler : BridgeHandler {
override val domain = BridgeDomain.CRYPTO
private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
override suspend fun handle(
method: String,
params: Map<String, JsonElement>,
): JsonElement? = when (method) {
"generateKey" -> generateKey(params)
"getPublicKey" -> getPublicKey(params)
"sign" -> sign(params)
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown crypto method: $method")
}
private fun generateKey(params: Map<String, JsonElement>): JsonElement {
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
// Delete existing key if present
if (keyStore.containsAlias(keyRef)) {
keyStore.deleteEntry(keyRef)
}
val spec = KeyGenParameterSpec.Builder(
keyRef,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.build()
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").apply {
initialize(spec)
generateKeyPair()
}
return JsonObject(mapOf(
"keyRef" to JsonPrimitive(keyRef),
"success" to JsonPrimitive(true),
))
}
private fun getPublicKey(params: Map<String, JsonElement>): JsonElement {
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
val cert = keyStore.getCertificate(keyRef)
?: throw BridgeHandlerException("KEY_NOT_FOUND", "Key not found: $keyRef")
val publicKeyBytes = cert.publicKey.encoded
val publicKeyB64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
return JsonObject(mapOf("publicKey" to JsonPrimitive(publicKeyB64)))
}
private fun sign(params: Map<String, JsonElement>): JsonElement {
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
val dataB64 = params["data"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "data parameter required")
val privateKey = keyStore.getKey(keyRef, null)
?: throw BridgeHandlerException("KEY_NOT_FOUND", "Key not found: $keyRef")
val dataBytes = Base64.decode(dataB64, Base64.DEFAULT)
val signature = Signature.getInstance("SHA256withECDSA").apply {
initSign(privateKey as java.security.PrivateKey)
update(dataBytes)
}.sign()
val signatureB64 = Base64.encodeToString(signature, Base64.NO_WRAP)
return JsonObject(mapOf("signature" to JsonPrimitive(signatureB64)))
}
}

View File

@@ -0,0 +1,53 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.handlers
import android.app.Activity
import android.content.Intent
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import xyz.self.sdk.bridge.BridgeDomain
import xyz.self.sdk.bridge.BridgeHandler
import xyz.self.sdk.bridge.BridgeHandlerException
import xyz.self.sdk.webview.SelfVerificationActivity
class LifecycleHandler(private val activity: Activity) : BridgeHandler {
override val domain = BridgeDomain.LIFECYCLE
override suspend fun handle(
method: String,
params: Map<String, JsonElement>,
): JsonElement? = when (method) {
"ready" -> null
"dismiss" -> dismiss()
"setResult" -> setResult(params)
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown lifecycle method: $method")
}
private fun dismiss(): JsonElement? {
activity.runOnUiThread {
activity.setResult(Activity.RESULT_CANCELED)
activity.finish()
}
return null
}
private fun setResult(params: Map<String, JsonElement>): JsonElement? {
activity.runOnUiThread {
val intent = Intent()
val result = params["result"]
if (result != null) {
intent.putExtra(SelfVerificationActivity.EXTRA_RESULT_DATA, result.toString())
val isSuccess = result.jsonObject["success"]?.jsonPrimitive?.booleanOrNull != false
val resultCode = if (isSuccess) Activity.RESULT_OK else Activity.RESULT_FIRST_USER
activity.setResult(resultCode, intent)
} else {
activity.setResult(Activity.RESULT_CANCELED)
}
activity.finish()
}
return null
}
}

View File

@@ -0,0 +1,72 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.handlers
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import xyz.self.sdk.bridge.BridgeDomain
import xyz.self.sdk.bridge.BridgeHandler
import xyz.self.sdk.bridge.BridgeHandlerException
class SecureStorageHandler(context: Context) : BridgeHandler {
override val domain = BridgeDomain.SECURE_STORAGE
private val prefs: SharedPreferences
// requireBiometric is intentionally ignored — device lock provides sufficient security per spec
init {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
"self_sdk_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override suspend fun handle(
method: String,
params: Map<String, JsonElement>,
): JsonElement? = when (method) {
"get" -> get(params)
"set" -> set(params)
"remove" -> remove(params)
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown secureStorage method: $method")
}
private fun get(params: Map<String, JsonElement>): JsonElement {
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
val value = prefs.getString(key, null)
return buildJsonObject {
put("value", if (value != null) JsonPrimitive(value) else JsonNull)
}
}
private fun set(params: Map<String, JsonElement>): JsonElement? {
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
val value = params["value"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required")
prefs.edit().putString(key, value).apply()
return null
}
private fun remove(params: Map<String, JsonElement>): JsonElement? {
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
prefs.edit().remove(key).apply()
return null
}
}

View File

@@ -0,0 +1,124 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import android.annotation.SuppressLint
import android.content.Context
import android.net.http.SslError
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewAssetLoader
import xyz.self.sdk.bridge.MessageRouter
class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
) {
private lateinit var webView: WebView
@SuppressLint("SetJavaScriptEnabled")
fun createWebView(queryParams: String): WebView {
val selfWalletHandler = WebViewAssetLoader.PathHandler { path ->
try {
val assetPath = "self-wallet/$path"
val inputStream = context.assets.open(assetPath)
val mimeType = when {
path.endsWith(".js") -> "application/javascript"
path.endsWith(".css") -> "text/css"
path.endsWith(".html") -> "text/html"
path.endsWith(".json") -> "application/json"
path.endsWith(".woff2") -> "font/woff2"
path.endsWith(".woff") -> "font/woff"
path.endsWith(".otf") -> "font/otf"
path.endsWith(".ttf") -> "font/ttf"
path.endsWith(".png") -> "image/png"
path.endsWith(".svg") -> "image/svg+xml"
else -> "application/octet-stream"
}
WebResourceResponse(mimeType, "UTF-8", inputStream)
} catch (e: Exception) {
null
}
}
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/", selfWalletHandler)
.build()
webView = WebView(context).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
mediaPlaybackRequiresUserGesture = false
if (isDebugMode) {
WebView.setWebContentsDebuggingEnabled(true)
}
}
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
request ?: return null
return assetLoader.shouldInterceptRequest(request.url)
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
if (url.startsWith("https://appassets.androidplatform.net/")) return false
if (isDebugMode && url.startsWith("http://127.0.0.1:5173")) return false
return true
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
handler?.cancel()
}
}
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
if (isDebugMode) {
loadUrl("http://127.0.0.1:5173?$queryParams")
} else {
loadUrl("https://appassets.androidplatform.net/index.html?$queryParams")
}
}
return webView
}
fun evaluateJs(js: String) {
if (!::webView.isInitialized) {
android.util.Log.e("WebViewHost", "evaluateJs called but webView not initialized")
return
}
webView.evaluateJavascript(js, null)
}
fun destroy() {
if (!::webView.isInitialized) return
webView.destroy()
}
inner class BridgeJsInterface {
@JavascriptInterface
fun postMessage(json: String) {
router.onMessageReceived(json)
}
}
}

View File

@@ -0,0 +1,60 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import xyz.self.sdk.bridge.MessageRouter
import xyz.self.sdk.handlers.CryptoHandler
import xyz.self.sdk.handlers.LifecycleHandler
import xyz.self.sdk.handlers.SecureStorageHandler
class SelfVerificationActivity : AppCompatActivity() {
private lateinit var webViewHost: AndroidWebViewHost
private lateinit var router: MessageRouter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isDebugMode = intent.getBooleanExtra(EXTRA_DEBUG_MODE, false)
val teeUrl = intent.getStringExtra(EXTRA_TEE_URL) ?: ""
val verificationId = intent.getStringExtra(EXTRA_VERIFICATION_ID) ?: ""
val userId = intent.getStringExtra(EXTRA_USER_ID) ?: ""
router = MessageRouter(
sendToWebView = { js ->
runOnUiThread { webViewHost.evaluateJs(js) }
},
)
router.register(SecureStorageHandler(this))
router.register(CryptoHandler())
router.register(LifecycleHandler(this))
webViewHost = AndroidWebViewHost(this, router, isDebugMode)
val queryParams = buildString {
append("teeUrl=").append(android.net.Uri.encode(teeUrl))
append("&verificationId=").append(android.net.Uri.encode(verificationId))
append("&userId=").append(android.net.Uri.encode(userId))
}
val webView = webViewHost.createWebView(queryParams)
setContentView(webView)
}
override fun onDestroy() {
if (::webViewHost.isInitialized) {
webViewHost.destroy()
}
super.onDestroy()
}
companion object {
const val EXTRA_DEBUG_MODE = "xyz.self.sdk.DEBUG_MODE"
const val EXTRA_TEE_URL = "xyz.self.sdk.TEE_URL"
const val EXTRA_VERIFICATION_ID = "xyz.self.sdk.VERIFICATION_ID"
const val EXTRA_USER_ID = "xyz.self.sdk.USER_ID"
const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA"
}
}

View File

@@ -0,0 +1,27 @@
// swift-tools-version: 5.9
// SPDX-License-Identifier: BUSL-1.1
import PackageDescription
let package = Package(
name: "SelfNativeShell",
platforms: [
.iOS(.v15)
],
products: [
.library(
name: "SelfNativeShell",
targets: ["SelfNativeShell"]
)
],
targets: [
.target(
name: "SelfNativeShell",
path: ".",
sources: ["Sources/SelfNativeShell"],
resources: [
.copy("Resources/self-sdk-web")
]
)
]
)

View File

@@ -0,0 +1,77 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
import UIKit
public final class SelfSdk {
public static func createViewController(
config: SelfSdkConfig,
callback: SelfSdkCallback
) -> UIViewController {
let viewController = SelfSdkViewController(config: config, callback: callback)
viewController.modalPresentationStyle = .fullScreen
return viewController
}
}
final class SelfSdkViewController: UIViewController {
private let config: SelfSdkConfig
private weak var callback: SelfSdkCallback?
private var webViewHost: SelfWebViewHost?
init(config: SelfSdkConfig, callback: SelfSdkCallback) {
self.config = config
self.callback = callback
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupWebView()
}
private func setupWebView() {
let lifecycleHandler = LifecycleHandler(
viewController: self,
onResult: { [weak self] result in
if let dict = result as? [String: Any] {
self?.callback?.onSuccess(result: dict)
} else {
self?.callback?.onSuccess(result: [:])
}
},
onDismiss: { [weak self] in
self?.callback?.onCancelled()
}
)
let router = MessageRouter { [weak self] js in
self?.webViewHost?.evaluateJs(js)
}
router.register(handler: SecureStorageHandler())
router.register(handler: CryptoHandler())
router.register(handler: lifecycleHandler)
let host = SelfWebViewHost(router: router, isDebugMode: config.isDebugMode)
self.webViewHost = host
let webView = host.createWebView()
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
NSLayoutConstraint.activate([
webView.topAnchor.constraint(equalTo: view.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
host.loadContent(queryParams: config.toQueryParams())
}
}

View File

@@ -0,0 +1,38 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
public struct SelfSdkConfig {
public let teeUrl: String
public let verificationId: String
public let userId: String
public let isDebugMode: Bool
public init(
teeUrl: String,
verificationId: String,
userId: String,
isDebugMode: Bool = false
) {
self.teeUrl = teeUrl
self.verificationId = verificationId
self.userId = userId
self.isDebugMode = isDebugMode
}
func toQueryParams() -> String {
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "teeUrl", value: teeUrl),
URLQueryItem(name: "verificationId", value: verificationId),
URLQueryItem(name: "userId", value: userId)
]
return components.percentEncodedQuery ?? ""
}
}
public protocol SelfSdkCallback: AnyObject {
func onSuccess(result: [String: Any])
func onFailure(error: Error)
func onCancelled()
}

View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
protocol BridgeHandler {
var domain: BridgeDomain { get }
func handle(method: String, params: [String: Any]?) async throws -> Any?
}
enum BridgeHandlerError: Error, LocalizedError {
case unknownMethod(String)
case missingParam(String)
case operationFailed(String)
var errorDescription: String? {
switch self {
case .unknownMethod(let method): return "Unknown method: \(method)"
case .missingParam(let param): return "Missing parameter: \(param)"
case .operationFailed(let reason): return reason
}
}
var code: String {
switch self {
case .unknownMethod: return "UNKNOWN_METHOD"
case .missingParam: return "MISSING_PARAM"
case .operationFailed: return "OPERATION_FAILED"
}
}
}

View File

@@ -0,0 +1,123 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
enum BridgeDomain: String, Codable {
case nfc
case biometrics
case secureStorage
case camera
case crypto
case haptic
case analytics
case lifecycle
case documents
case navigation
}
struct BridgeRequest: Codable {
let type: String
let version: Int
let id: String
let domain: BridgeDomain
let method: String
let params: [String: AnyCodable]?
let timestamp: Double
}
struct BridgeResponse: Codable {
let type: String
let version: Int
let id: String
let domain: BridgeDomain
let requestId: String
let success: Bool
let data: AnyCodable?
let error: BridgeError?
let timestamp: Double
static func success(request: BridgeRequest, result: Any?) -> BridgeResponse {
BridgeResponse(
type: "response",
version: 1,
id: UUID().uuidString,
domain: request.domain,
requestId: request.id,
success: true,
data: result.map { AnyCodable($0) },
error: nil,
timestamp: Date().timeIntervalSince1970 * 1000
)
}
static func failure(request: BridgeRequest, code: String, message: String) -> BridgeResponse {
BridgeResponse(
type: "response",
version: 1,
id: UUID().uuidString,
domain: request.domain,
requestId: request.id,
success: false,
data: nil,
error: BridgeError(code: code, message: message),
timestamp: Date().timeIntervalSince1970 * 1000
)
}
}
struct BridgeError: Codable {
let code: String
let message: String
}
// Type-erased Codable wrapper for heterogeneous JSON values
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
value = NSNull()
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let string = try? container.decode(String.self) {
value = string
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map { $0.value }
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues { $0.value }
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON type")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case is NSNull:
try container.encodeNil()
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
default:
throw EncodingError.invalidValue(value, .init(codingPath: encoder.codingPath, debugDescription: "Unsupported type"))
}
}
}

View File

@@ -0,0 +1,113 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
final class MessageRouter {
private var handlers: [BridgeDomain: BridgeHandler] = [:]
private let sendToWebView: (String) -> Void
init(sendToWebView: @escaping (String) -> Void) {
self.sendToWebView = sendToWebView
}
func register(handler: BridgeHandler) {
handlers[handler.domain] = handler
}
func onMessageReceived(rawJson: String) {
guard let data = rawJson.data(using: .utf8) else {
return
}
let request: BridgeRequest
do {
request = try JSONDecoder().decode(BridgeRequest.self, from: data)
} catch {
return
}
guard request.version == 1 else {
let response = BridgeResponse.failure(
request: request,
code: "UNSUPPORTED_VERSION",
message: "Protocol version \(request.version) is not supported"
)
sendResponse(response)
return
}
guard let handler = handlers[request.domain] else {
let response = BridgeResponse.failure(
request: request,
code: "UNKNOWN_DOMAIN",
message: "No handler for domain: \(request.domain.rawValue)"
)
sendResponse(response)
return
}
let params = request.params?.mapValues { $0.value }
Task {
do {
let result = try await handler.handle(method: request.method, params: params)
let response = BridgeResponse.success(request: request, result: result)
await MainActor.run { sendResponse(response) }
} catch let error as BridgeHandlerError {
let response = BridgeResponse.failure(
request: request,
code: error.code,
message: error.errorDescription ?? "Unknown error"
)
await MainActor.run { sendResponse(response) }
} catch {
let response = BridgeResponse.failure(
request: request,
code: "HANDLER_ERROR",
message: error.localizedDescription
)
await MainActor.run { sendResponse(response) }
}
}
}
func pushEvent(domain: BridgeDomain, event: String, data: Any?) {
let eventDict: [String: Any] = [
"type": "event",
"version": 1,
"domain": domain.rawValue,
"event": event,
"data": data as Any,
"timestamp": Date().timeIntervalSince1970 * 1000
]
guard let jsonData = try? JSONSerialization.data(withJSONObject: eventDict),
let jsonStr = String(data: jsonData, encoding: .utf8) else {
return
}
let escaped = escapeForJs(jsonStr)
let js = "window.SelfNativeBridge._handleEvent('\(escaped)')"
sendToWebView(js)
}
private func sendResponse(_ response: BridgeResponse) {
guard let data = try? JSONEncoder().encode(response),
let jsonStr = String(data: data, encoding: .utf8) else {
return
}
let escaped = escapeForJs(jsonStr)
let js = "window.SelfNativeBridge._handleResponse('\(escaped)')"
sendToWebView(js)
}
private func escapeForJs(_ str: String) -> String {
str.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\u{2028}", with: "\\u2028")
.replacingOccurrences(of: "\u{2029}", with: "\\u2029")
}
}

Some files were not shown because too many files have changed in this diff Show More