Merge pull request #1494 from selfxyz/release/staging-2025-12-12

Release to Staging - 2025-12-12
This commit is contained in:
Justin Hernandez
2025-12-13 23:21:54 -08:00
committed by GitHub
95 changed files with 9093 additions and 534 deletions

View File

@@ -0,0 +1,56 @@
name: "Generate GitHub App Token"
description: "Generates a GitHub App token for accessing repositories in the selfxyz organization"
inputs:
app-id:
description: "The GitHub App ID"
required: true
private-key:
description: "The GitHub App private key"
required: true
configure-netrc:
description: "If true, writes a ~/.netrc entry for github.com using the generated token (useful for CocoaPods / git HTTPS fetches)"
required: false
default: "false"
netrc-machine:
description: "The machine hostname to write into ~/.netrc (default: github.com)"
required: false
default: "github.com"
owner:
description: "The owner (organization) of the repositories"
required: false
default: "selfxyz"
repositories:
description: "Comma-separated list of repository names to grant access to"
required: false
default: "NFCPassportReader,android-passport-nfc-reader,react-native-passport-reader,mobile-sdk-native"
outputs:
token:
description: "The generated GitHub App installation token"
value: ${{ steps.app-token.outputs.token }}
runs:
using: "composite"
steps:
- name: Generate GitHub App Token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
id: app-token
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ inputs.private-key }}
owner: ${{ inputs.owner }}
repositories: ${{ inputs.repositories }}
- name: Configure Git auth via ~/.netrc (optional)
if: ${{ inputs.configure-netrc == 'true' }}
shell: bash
run: |
set -euo pipefail
TOKEN="${{ steps.app-token.outputs.token }}"
MACHINE="${{ inputs.netrc-machine }}"
# Mask the token in logs defensively (it shouldn't print, but this protects against future edits).
echo "::add-mask::${TOKEN}"
printf "machine %s\n login x-access-token\n password %s\n" "${MACHINE}" "${TOKEN}" > "${HOME}/.netrc"
chmod 600 "${HOME}/.netrc"

View File

@@ -5,6 +5,7 @@ env:
JAVA_VERSION: 17
WORKSPACE: ${{ github.workspace }}
APP_PATH: ${{ github.workspace }}/app
NODE_ENV: "production"
on:
pull_request:
@@ -57,6 +58,14 @@ jobs:
path: |
~/.gradle/caches
~/.gradle/wrapper
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install Mobile Dependencies
uses: ./.github/actions/mobile-setup
with:
@@ -65,7 +74,7 @@ jobs:
ruby_version: ${{ env.RUBY_VERSION }}
workspace: ${{ env.WORKSPACE }}
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Build dependencies
shell: bash
run: yarn workspace @selfxyz/common build
@@ -113,6 +122,14 @@ jobs:
with:
path: app/ios/Pods
lockfile: app/ios/Podfile.lock
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install Mobile Dependencies
uses: ./.github/actions/mobile-setup
with:
@@ -121,7 +138,7 @@ jobs:
ruby_version: ${{ env.RUBY_VERSION }}
workspace: ${{ env.WORKSPACE }}
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Build dependencies
shell: bash
run: yarn workspace @selfxyz/common build

View File

@@ -35,7 +35,7 @@ concurrency:
jobs:
build-deps:
runs-on: macos-latest-large
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
@@ -90,12 +90,9 @@ jobs:
- name: Check App Types
run: yarn types
working-directory: ./app
- name: Check license headers
run: node scripts/check-license-headers.mjs --check
working-directory: ./
test:
runs-on: macos-latest-large
runs-on: ubuntu-latest
needs: build-deps
timeout-minutes: 60
steps:
@@ -190,6 +187,8 @@ jobs:
env:
# Increase Node.js memory to prevent hermes-parser WASM memory errors
NODE_OPTIONS: --max-old-space-size=4096
# Override production NODE_ENV for tests - React's production build doesn't include testing utilities
NODE_ENV: test
run: |
# Final verification from app directory perspective
echo "Final verification before running tests (from app directory)..."
@@ -268,6 +267,7 @@ jobs:
- name: Cache Ruby gems
uses: ./.github/actions/cache-bundler
with:
# TODO(jcortejoso): Confirm the path of the bundle cache
path: app/ios/vendor/bundle
lock-file: app/Gemfile.lock
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-ruby${{ env.RUBY_VERSION }}
@@ -315,6 +315,14 @@ jobs:
bundle config set --local path 'vendor/bundle'
bundle install --jobs 4 --retry 3
working-directory: ./app
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install iOS Dependencies
uses: nick-fields/retry@v3
with:
@@ -325,7 +333,7 @@ jobs:
cd app/ios
bundle exec bash scripts/pod-install-with-cache-fix.sh
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Resolve iOS workspace
run: |
WORKSPACE_OPEN="ios/OpenPassport.xcworkspace"
@@ -470,12 +478,19 @@ jobs:
run: |
echo "Cache miss for built dependencies. Building now..."
yarn workspace @selfxyz/mobile-app run build:deps
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
- name: Setup Android private modules
run: |
cd ${{ env.APP_PATH }}
PLATFORM=android node scripts/setup-private-modules.cjs
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
CI: true
- name: Build Android (with AAPT2 symlink fix)
run: yarn android:ci

View File

@@ -31,6 +31,7 @@ name: Mobile Deploy
env:
# Build environment versions
RUBY_VERSION: 3.2
NODE_ENV: "production"
JAVA_VERSION: 17
ANDROID_API_LEVEL: 35
ANDROID_NDK_VERSION: 27.0.12077973
@@ -385,6 +386,7 @@ jobs:
id: gems-cache
uses: ./.github/actions/cache-bundler
with:
# TODO(jcortejoso): Confirm the path of the bundle cache
path: ${{ env.APP_PATH }}/ios/vendor/bundle
lock-file: app/Gemfile.lock
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-ruby${{ env.RUBY_VERSION }}
@@ -428,6 +430,14 @@ jobs:
fi
echo "✅ Lock files exist"
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install Mobile Dependencies (main repo)
if: inputs.platform != 'android' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
@@ -438,7 +448,7 @@ jobs:
ruby_version: ${{ env.RUBY_VERSION }}
workspace: ${{ env.WORKSPACE }}
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Install Mobile Dependencies (forked PRs - no secrets)
if: inputs.platform != 'android' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
@@ -691,7 +701,7 @@ jobs:
IOS_TESTFLIGHT_GROUPS: ${{ secrets.IOS_TESTFLIGHT_GROUPS }}
NODE_OPTIONS: "--max-old-space-size=8192"
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
@@ -1046,6 +1056,14 @@ jobs:
echo "✅ Lock files exist"
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
- name: Install Mobile Dependencies (main repo)
if: inputs.platform != 'ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
uses: ./.github/actions/mobile-setup
@@ -1055,7 +1073,7 @@ jobs:
ruby_version: ${{ env.RUBY_VERSION }}
workspace: ${{ env.WORKSPACE }}
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
PLATFORM: ${{ inputs.platform }}
- name: Install Mobile Dependencies (forked PRs - no secrets)
@@ -1112,7 +1130,7 @@ jobs:
cd ${{ env.APP_PATH }}
PLATFORM=android node scripts/setup-private-modules.cjs
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
CI: true
- name: Build Dependencies (Android)

View File

@@ -70,6 +70,14 @@ jobs:
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install deps (internal PRs and protected branches)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: nick-fields/retry@v3
@@ -79,7 +87,7 @@ jobs:
retry_wait_seconds: 5
command: yarn install --immutable --silent
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Install deps (forked PRs - no secrets)
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
uses: nick-fields/retry@v3
@@ -138,7 +146,7 @@ jobs:
cd app
PLATFORM=android node scripts/setup-private-modules.cjs
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
CI: true
- name: Build Android APK
run: |
@@ -149,6 +157,8 @@ jobs:
- name: Clean up Gradle build artifacts
uses: ./.github/actions/cleanup-gradle-artifacts
- name: Verify APK and android-passport-nfc-reader integration
env:
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
run: |
echo "🔍 Verifying build artifacts..."
APK_PATH="app/android/app/build/outputs/apk/debug/app-debug.apk"
@@ -160,8 +170,8 @@ jobs:
echo "📱 APK size: $APK_SIZE bytes"
# Verify private modules were properly integrated (skip for forks)
if [ -z "${SELFXYZ_INTERNAL_REPO_PAT:-}" ]; then
echo "🔕 No PAT available — skipping private module verification"
if [ -z "${SELFXYZ_APP_TOKEN:-}" ]; then
echo "🔕 No SELFXYZ_APP_TOKEN available — skipping private module verification"
else
# Verify android-passport-nfc-reader
if [ -d "app/android/android-passport-nfc-reader" ]; then
@@ -263,6 +273,14 @@ jobs:
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install deps (internal PRs and protected branches)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: nick-fields/retry@v3
@@ -272,7 +290,7 @@ jobs:
retry_wait_seconds: 5
command: yarn install --immutable --silent
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Install deps (forked PRs - no secrets)
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
uses: nick-fields/retry@v3
@@ -360,7 +378,7 @@ jobs:
echo "📦 Installing pods via centralized script…"
BUNDLE_GEMFILE=../Gemfile bundle exec bash scripts/pod-install-with-cache-fix.sh
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Setup iOS Simulator
run: |
echo "Setting up iOS Simulator..."

View File

@@ -59,6 +59,9 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
- name: Cache Yarn dependencies
uses: ./.github/actions/cache-yarn
with:
@@ -66,10 +69,17 @@ jobs:
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
- name: Install deps (internal PRs and protected branches)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: nick-fields/retry@v3
@@ -79,7 +89,7 @@ jobs:
retry_wait_seconds: 5
command: yarn install --immutable --silent
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Install deps (forked PRs - no secrets)
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
uses: nick-fields/retry@v3
@@ -220,6 +230,9 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
- name: Cache Yarn dependencies
uses: ./.github/actions/cache-yarn
with:
@@ -227,10 +240,17 @@ jobs:
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
- name: Install deps (internal PRs and protected branches)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: nick-fields/retry@v3
@@ -240,7 +260,7 @@ jobs:
retry_wait_seconds: 5
command: yarn install --immutable --silent
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
- name: Install deps (forked PRs - no secrets)
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
uses: nick-fields/retry@v3
@@ -316,15 +336,15 @@ jobs:
max_attempts: 3
retry_wait_seconds: 10
command: |
if [ -n "${SELFXYZ_INTERNAL_REPO_PAT}" ]; then
echo "🔑 Using SELFXYZ_INTERNAL_REPO_PAT for private pod access"
echo "::add-mask::${SELFXYZ_INTERNAL_REPO_PAT}"
if [ -n "${SELFXYZ_APP_TOKEN}" ]; then
echo "🔑 Using GitHub App token for private pod access"
echo "::add-mask::${SELFXYZ_APP_TOKEN}"
fi
cd packages/mobile-sdk-demo/ios
echo "📦 Installing pods via cache-fix script…"
bash scripts/pod-install-with-cache-fix.sh
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
GIT_TERMINAL_PROMPT: 0
- name: Setup iOS Simulator
run: |

View File

@@ -76,6 +76,7 @@ jobs:
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
@@ -128,6 +129,7 @@ jobs:
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
fail-on-cache-miss: true
@@ -195,6 +197,7 @@ jobs:
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
fail-on-cache-miss: true
@@ -203,6 +206,7 @@ jobs:
run: |
echo "Verifying build artifacts..."
ls -la common/dist/
ls -la sdk/sdk-common/dist/
ls -la sdk/qrcode/dist/
echo "✅ Build artifacts verified"
@@ -255,13 +259,11 @@ jobs:
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
fail-on-cache-miss: true
- name: Build SDK common dependency
run: yarn workspace @selfxyz/sdk-common build
- name: Run tests
run: yarn workspace @selfxyz/qrcode test

5
.gitignore vendored
View File

@@ -24,3 +24,8 @@ packages/mobile-sdk-alpha/docs/docstrings-report.json
# Private Android modules (cloned at build time)
app/android/android-passport-nfc-reader/
# Foundry
contracts/out/
contracts/cache_forge/
contracts/broadcast/

7
.gitmodules vendored
View File

@@ -1,3 +1,10 @@
[submodule "contracts/lib/openzeppelin-foundry-upgrades"]
path = contracts/lib/openzeppelin-foundry-upgrades
url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades
[submodule "contracts/lib/forge-std"]
path = contracts/lib/forge-std
url = https://github.com/foundry-rs/forge-std
[submodule "packages/mobile-sdk-alpha/mobile-sdk-native"]
path = packages/mobile-sdk-alpha/mobile-sdk-native
url = git@github.com:selfxyz/mobile-sdk-native.git

View File

@@ -245,6 +245,8 @@ module.exports = {
],
// Allow any types in tests for mocking
'@typescript-eslint/no-explicit-any': 'off',
// Allow test skipping without warnings
'jest/no-disabled-tests': 'off',
},
},
{

View File

@@ -22,7 +22,7 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1190.0)
aws-partitions (1.1194.0)
aws-sdk-core (3.239.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -86,7 +86,7 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
declarative (0.0.20)
digest-crc (0.7.0)
@@ -222,14 +222,14 @@ GEM
i18n (1.14.7)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.17.1)
json (2.18.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.2)
minitest (5.27.0)
molinillo (0.8.0)
multi_json (1.18.0)
multipart-post (2.4.1)
@@ -241,7 +241,7 @@ GEM
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
optparse (0.8.0)
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
public_suffix (4.0.7)

View File

@@ -26,4 +26,9 @@ module.exports = {
},
],
],
env: {
production: {
plugins: ['transform-remove-console'],
},
},
};

View File

@@ -42,8 +42,15 @@
<string></string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>whatsapp</string>
<string>argent</string>
<string>cbwallet</string>
<string>coinbase</string>
<string>metamask</string>
<string>rainbow</string>
<string>sms</string>
<string>trust</string>
<string>wc</string>
<string>whatsapp</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>

View File

@@ -33,7 +33,7 @@ def using_https_git_auth?
auth_data.include?("Logged in to github.com account") &&
auth_data.include?("Git operations protocol: https")
rescue => e
puts "gh auth status failed, assuming no HTTPS auth -- will try SSH"
# Avoid printing auth-related details in CI logs.
false
end
end
@@ -51,18 +51,16 @@ target "Self" do
# External fork - use public NFCPassportReader repository (placeholder)
# TODO: Replace with actual public NFCPassportReader repository URL
nfc_repo_url = "https://github.com/PLACEHOLDER/NFCPassportReader.git"
puts "📦 Using public NFCPassportReader for external fork (#{ENV["GITHUB_REPOSITORY"]})"
elsif ENV["GITHUB_ACTIONS"] == "true" && ENV["SELFXYZ_INTERNAL_REPO_PAT"]
# Running in selfxyz GitHub Actions with PAT available - use private repo with token
nfc_repo_url = "https://#{ENV["SELFXYZ_INTERNAL_REPO_PAT"]}@github.com/selfxyz/NFCPassportReader.git"
puts "📦 Using private NFCPassportReader with PAT (selfxyz GitHub Actions)"
elsif ENV["GITHUB_ACTIONS"] == "true"
# CI: NEVER embed credentials in URLs. Rely on workflow-provided auth via:
# - ~/.netrc or a Git credential helper, and token masking in logs.
nfc_repo_url = "https://github.com/selfxyz/NFCPassportReader.git"
elsif using_https_git_auth?
# Local development with HTTPS GitHub auth via gh - use HTTPS to private repo
nfc_repo_url = "https://github.com/selfxyz/NFCPassportReader.git"
else
# Local development in selfxyz repo - use SSH to private repo
nfc_repo_url = "git@github.com:selfxyz/NFCPassportReader.git"
puts "📦 Using SSH for private NFCPassportReader (local selfxyz development)"
end
pod "NFCPassportReader", git: nfc_repo_url, commit: "9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b"

View File

@@ -2644,6 +2644,6 @@ SPEC CHECKSUMS:
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
PODFILE CHECKSUM: b5f11f935be22fce84c5395aaa203b50427a79aa
PODFILE CHECKSUM: 0aa47f53692543349c43673cda7380fa23049eba
COCOAPODS: 1.16.2

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)/)',
'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)/)',
],
setupFiles: ['<rootDir>/jest.setup.js'],
testMatch: [

View File

@@ -58,7 +58,7 @@
"sync-versions": "bundle exec fastlane ios sync_version && bundle exec fastlane android sync_version",
"tag:release": "node scripts/tag.cjs release",
"tag:remove": "node scripts/tag.cjs remove",
"test": "yarn build:deps && yarn jest:run --passWithNoTests && node --test scripts/tests/*.cjs",
"test": "yarn jest:run --passWithNoTests && node --test scripts/tests/*.cjs",
"test:build": "yarn build:deps && yarn types && node ./scripts/bundle-analyze-ci.cjs ios && yarn test",
"test:ci": "yarn jest:run --passWithNoTests && node --test scripts/tests/*.cjs",
"test:coverage": "yarn jest:run --coverage --passWithNoTests",
@@ -105,6 +105,7 @@
"@segment/analytics-react-native": "^2.21.2",
"@segment/sovran-react-native": "^1.1.3",
"@selfxyz/common": "workspace:^",
"@selfxyz/euclid": "^0.6.0",
"@selfxyz/mobile-sdk-alpha": "workspace:^",
"@sentry/react": "^9.32.0",
"@sentry/react-native": "7.0.1",
@@ -209,6 +210,7 @@
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-transform-remove-console": "^6.9.4",
"constants-browserify": "^1.0.0",
"dompurify": "^3.2.6",
"eslint": "^8.57.0",

View File

@@ -17,8 +17,8 @@ if (!platform || !['android', 'ios'].includes(platform)) {
// Bundle size thresholds in MB - easy to update!
const BUNDLE_THRESHOLDS_MB = {
// TODO: fix temporary bundle bump
ios: 44,
android: 44,
ios: 45,
android: 45,
};
function formatBytes(bytes) {

View File

@@ -109,7 +109,13 @@ clone_private_module() {
local dir_name=$(basename "$target_dir")
# Use different clone methods based on environment
if is_ci && [[ -n "${SELFXYZ_INTERNAL_REPO_PAT:-}" ]]; then
if is_ci && [[ -n "${SELFXYZ_APP_TOKEN:-}" ]]; then
# CI environment with GitHub App installation token
git clone "https://x-access-token:${SELFXYZ_APP_TOKEN}@github.com/selfxyz/${repo_name}.git" "$dir_name" || {
log "ERROR: Failed to clone $repo_name with GitHub App token"
exit 1
}
elif is_ci && [[ -n "${SELFXYZ_INTERNAL_REPO_PAT:-}" ]]; then
# CI environment with PAT (fallback if action didn't run)
git clone "https://${SELFXYZ_INTERNAL_REPO_PAT}@github.com/selfxyz/${repo_name}.git" "$dir_name" || {
log "ERROR: Failed to clone $repo_name with PAT"
@@ -119,14 +125,14 @@ clone_private_module() {
# Local development with SSH
git clone "git@github.com:selfxyz/${repo_name}.git" "$dir_name" || {
log "ERROR: Failed to clone $repo_name with SSH"
log "Please ensure you have SSH access to the repository or set SELFXYZ_INTERNAL_REPO_PAT"
log "Please ensure you have SSH access to the repository or set SELFXYZ_APP_TOKEN/SELFXYZ_INTERNAL_REPO_PAT"
exit 1
}
else
log "ERROR: No authentication method available for cloning $repo_name"
log "Please either:"
log " - Set up SSH access (for local development)"
log " - Set SELFXYZ_INTERNAL_REPO_PAT environment variable (for CI)"
log " - Set SELFXYZ_APP_TOKEN or SELFXYZ_INTERNAL_REPO_PAT environment variable (for CI)"
exit 1
fi
@@ -194,14 +200,15 @@ log "✅ Package files backed up successfully"
# Install SDK from tarball in app with timeout
log "Installing SDK as real files..."
if is_ci; then
# Temporarily unset PAT to skip private modules during SDK installation
env -u SELFXYZ_INTERNAL_REPO_PAT timeout 180 yarn add "@selfxyz/mobile-sdk-alpha@file:$TARBALL_PATH" || {
# Temporarily unset both auth tokens to skip private modules during SDK installation
# Both tokens must be unset to prevent setup-private-modules.cjs from attempting clones
env -u SELFXYZ_INTERNAL_REPO_PAT -u SELFXYZ_APP_TOKEN timeout 180 yarn add "@selfxyz/mobile-sdk-alpha@file:$TARBALL_PATH" || {
log "SDK installation timed out after 3 minutes"
exit 1
}
else
# Temporarily unset PAT to skip private modules during SDK installation
env -u SELFXYZ_INTERNAL_REPO_PAT yarn add "@selfxyz/mobile-sdk-alpha@file:$TARBALL_PATH"
# Temporarily unset both auth tokens to skip private modules during SDK installation
env -u SELFXYZ_INTERNAL_REPO_PAT -u SELFXYZ_APP_TOKEN yarn add "@selfxyz/mobile-sdk-alpha@file:$TARBALL_PATH"
fi
# Verify installation (check for AAR file in both local and hoisted locations)

View File

@@ -29,8 +29,9 @@ const PRIVATE_MODULES = [
// Environment detection
// CI is set by GitHub Actions, CircleCI, etc. Check for truthy value
const isCI = !!process.env.CI || process.env.GITHUB_ACTIONS === 'true';
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
const repoToken = process.env.SELFXYZ_INTERNAL_REPO_PAT;
const appToken = process.env.SELFXYZ_APP_TOKEN; // GitHub App installation token
const isDryRun = process.env.DRY_RUN === 'true';
// Platform detection for Android-specific modules
@@ -150,13 +151,17 @@ function clonePrivateRepo(repoName, localPath) {
let cloneUrl;
if (isCI && repoToken) {
if (isCI && appToken) {
// CI environment with GitHub App installation token
log('CI detected: Using SELFXYZ_APP_TOKEN for clone', 'info');
cloneUrl = `https://x-access-token:${appToken}@github.com/${GITHUB_ORG}/${repoName}.git`;
} else if (isCI && repoToken) {
// CI environment with Personal Access Token
log('CI detected: Using SELFXYZ_INTERNAL_REPO_PAT for clone', 'info');
cloneUrl = `https://${repoToken}@github.com/${GITHUB_ORG}/${repoName}.git`;
} else if (isCI) {
log(
'CI environment detected but SELFXYZ_INTERNAL_REPO_PAT not available - skipping private module setup',
'CI environment detected but no token available - skipping private module setup',
'info',
);
log(
@@ -173,7 +178,7 @@ function clonePrivateRepo(repoName, localPath) {
}
// Security: Use quiet mode for credentialed URLs to prevent token exposure
const isCredentialedUrl = isCI && repoToken;
const isCredentialedUrl = isCI && (appToken || repoToken);
const quietFlag = isCredentialedUrl ? '--quiet' : '';
const targetDir = path.basename(localPath);
const cloneCommand = `git clone --branch ${BRANCH} --single-branch --depth 1 ${quietFlag} "${cloneUrl}" "${targetDir}"`;
@@ -190,7 +195,7 @@ function clonePrivateRepo(repoName, localPath) {
} catch (error) {
if (isCI) {
log(
'Clone failed in CI environment. Check SELFXYZ_INTERNAL_REPO_PAT permissions.',
'Clone failed in CI environment. Check SELFXYZ_APP_TOKEN or SELFXYZ_INTERNAL_REPO_PAT permissions.',
'error',
);
} else {
@@ -231,7 +236,7 @@ function setupPrivateModule(module) {
}
// Security: Remove credential-embedded remote URL after clone
if (isCI && repoToken && !isDryRun) {
if (isCI && (appToken || repoToken) && !isDryRun) {
scrubGitRemoteUrl(localPath, repoName);
}
@@ -275,6 +280,11 @@ function setupAndroidPassportReader() {
`Setup complete: ${successCount}/${PRIVATE_MODULES.length} modules cloned`,
'warning',
);
} else {
log(
'No private modules were cloned - this is expected for forked PRs',
'info',
);
}
}

View File

@@ -6,11 +6,7 @@ import React from 'react';
import { ArrowLeft, ArrowRight, RotateCcw } from '@tamagui/lucide-icons';
import { Button, XStack, YStack } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
slate50,
slate400,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { black, slate400 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { buttonTap } from '@/integrations/haptics';
@@ -23,8 +19,7 @@ export interface WebViewFooterProps {
onOpenInBrowser: () => void;
}
const iconSize = 22;
const buttonSize = 36;
const iconSize = 24;
export const WebViewFooter: React.FC<WebViewFooterProps> = ({
canGoBack,
@@ -42,19 +37,13 @@ export const WebViewFooter: React.FC<WebViewFooterProps> = ({
) => (
<Button
key={key}
size="$4"
unstyled
disabled={disabled}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
onPress={() => {
buttonTap();
onPress();
}}
backgroundColor={slate50}
borderRadius={buttonSize / 2}
width={buttonSize}
height={buttonSize}
alignItems="center"
justifyContent="center"
opacity={disabled ? 0.5 : 1}
>
{icon}
@@ -62,7 +51,7 @@ export const WebViewFooter: React.FC<WebViewFooterProps> = ({
);
return (
<YStack gap={12} paddingVertical={12} width="100%">
<YStack gap={4} paddingVertical={4} paddingHorizontal={5} width="100%">
<XStack justifyContent="space-between" alignItems="center" width="100%">
{renderIconButton(
'back',

View File

@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { SystemBars } from 'react-native-edge-to-edge';
import type { NativeStackHeaderProps } from '@react-navigation/native-stack';
export const HeadlessNavForEuclid = (props: NativeStackHeaderProps) => {
return (
<>
<SystemBars
style={props.options.statusBarStyle}
hidden={props.options.statusBarHidden}
/>
</>
);
};

View File

@@ -30,9 +30,9 @@ export const WebViewNavBar: React.FC<WebViewNavBarProps> = ({
return (
<XStack
paddingHorizontal={20}
paddingVertical={10}
paddingTop={insets.top + 10}
paddingHorizontal={16}
gap={14}
alignItems="center"
backgroundColor="white"
@@ -50,7 +50,12 @@ export const WebViewNavBar: React.FC<WebViewNavBarProps> = ({
/>
{/* Center: Title */}
<XStack flex={1} alignItems="center" justifyContent="center">
<XStack
flex={1}
alignItems="center"
justifyContent="center"
paddingHorizontal={8}
>
<Text style={styles.title} numberOfLines={1}>
{title?.toUpperCase() || 'PAGE TITLE'}
</Text>

View File

@@ -0,0 +1,16 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Platform } from 'react-native';
import { useSafeAreaInsets as useSafeAreaInsetsOriginal } from 'react-native-safe-area-context';
// gives bare minimums in case safe area doesnt provide for example space for status bar icons.
export function useSafeAreaInsets() {
const insets = useSafeAreaInsetsOriginal();
const minimum = Platform.select({ ios: 54, android: 26, web: 48 });
return {
...insets,
top: Math.max(insets.top, minimum || 0),
};
}

View File

@@ -10,6 +10,7 @@ import {
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { HeadlessNavForEuclid } from '@/components/navbar/HeadlessNavForEuclid';
import AccountRecoveryChoiceScreen from '@/screens/account/recovery/AccountRecoveryChoiceScreen';
import AccountRecoveryScreen from '@/screens/account/recovery/AccountRecoveryScreen';
import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataNotFoundScreen';
@@ -17,6 +18,7 @@ import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhras
import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen';
import SettingsScreen from '@/screens/account/settings/SettingsScreen';
import ShowRecoveryPhraseScreen from '@/screens/account/settings/ShowRecoveryPhraseScreen';
import { IS_EUCLID_ENABLED } from '@/utils/devUtils';
const accountScreens = {
AccountRecovery: {
@@ -79,14 +81,22 @@ const accountScreens = {
screens: {},
},
},
ShowRecoveryPhrase: {
screen: ShowRecoveryPhraseScreen,
options: {
title: 'Recovery Phrase',
headerStyle: {
backgroundColor: white,
},
} as NativeStackNavigationOptions,
options: IS_EUCLID_ENABLED
? ({
headerShown: true,
header: HeadlessNavForEuclid,
statusBarStyle: ShowRecoveryPhraseScreen.statusBarStyle,
statusBarHidden: ShowRecoveryPhraseScreen.statusBarHidden,
} as NativeStackNavigationOptions)
: ({
title: 'Recovery Phrase',
headerStyle: {
backgroundColor: white,
},
} as NativeStackNavigationOptions),
},
};

View File

@@ -7,6 +7,7 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { AadhaarNavBar, IdDetailsNavBar } from '@/components/navbar';
import { HeadlessNavForEuclid } from '@/components/navbar/HeadlessNavForEuclid';
import AadhaarUploadedSuccessScreen from '@/screens/documents/aadhaar/AadhaarUploadedSuccessScreen';
import AadhaarUploadErrorScreen from '@/screens/documents/aadhaar/AadhaarUploadErrorScreen';
import AadhaarUploadScreen from '@/screens/documents/aadhaar/AadhaarUploadScreen';
@@ -76,7 +77,10 @@ const documentsScreens = {
CountryPicker: {
screen: CountryPickerScreen,
options: {
headerShown: false,
header: HeadlessNavForEuclid,
statusBarHidden: CountryPickerScreen.statusBar?.hidden,
statusBarStyle: CountryPickerScreen.statusBar?.style,
headerShown: true,
} as NativeStackNavigationOptions,
},
IDPicker: {

View File

@@ -2,21 +2,85 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import type { RecoveryPhraseVariant } from '@selfxyz/euclid';
import { RecoveryPhraseScreen } from '@selfxyz/euclid';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { Description } from '@selfxyz/mobile-sdk-alpha/components';
import Mnemonic from '@/components/Mnemonic';
import useMnemonic from '@/hooks/useMnemonic';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { useSettingStore } from '@/stores/settingStore';
import { IS_EUCLID_ENABLED } from '@/utils/devUtils';
const ShowRecoveryPhraseScreen: React.FC = () => {
function useCopyRecoveryPhrase(mnemonic: string[] | undefined) {
const [copied, setCopied] = React.useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const onCopy = useCallback(() => {
if (!mnemonic) return;
Clipboard.setString(mnemonic.join(' '));
setCopied(true);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout and store its ID
timeoutRef.current = setTimeout(() => setCopied(false), 2500);
}, [mnemonic]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return { copied, onCopy };
}
const ShowRecoveryPhraseScreen: React.FC & {
statusBarStyle: string;
statusBarHidden: boolean;
} = () => {
const { mnemonic, loadMnemonic } = useMnemonic();
const self = useSelfClient();
const { copied, onCopy } = useCopyRecoveryPhrase(mnemonic);
const { setHasViewedRecoveryPhrase } = useSettingStore();
const onRevealWords = useCallback(async () => {
const onReveal = useCallback(async () => {
await loadMnemonic();
}, [loadMnemonic]);
setHasViewedRecoveryPhrase(true);
}, [loadMnemonic, setHasViewedRecoveryPhrase]);
const insets = useSafeAreaInsets();
if (IS_EUCLID_ENABLED) {
const variant: RecoveryPhraseVariant = !mnemonic
? 'hidden'
: copied
? 'copied'
: 'revealed';
return (
<>
<RecoveryPhraseScreen
insets={insets}
onReveal={onReveal}
words={mnemonic}
onBack={self.goBack}
variant={variant}
onCopy={onCopy}
/>
</>
);
}
return (
<ExpandableBottomLayout.Layout backgroundColor="white">
<ExpandableBottomLayout.BottomSection
@@ -24,7 +88,7 @@ const ShowRecoveryPhraseScreen: React.FC = () => {
justifyContent="center"
gap={20}
>
<Mnemonic words={mnemonic} onRevealWords={onRevealWords} />
<Mnemonic words={mnemonic} onRevealWords={loadMnemonic} />
<Description>
This phrase is the only way to recover your account. Keep it secret,
keep it safe.
@@ -35,3 +99,7 @@ const ShowRecoveryPhraseScreen: React.FC = () => {
};
export default ShowRecoveryPhraseScreen;
ShowRecoveryPhraseScreen.statusBarHidden =
RecoveryPhraseScreen.statusBar.hidden;
ShowRecoveryPhraseScreen.statusBarStyle = RecoveryPhraseScreen.statusBar.style;

View File

@@ -2,8 +2,21 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type React from 'react';
import SDKCountryPickerScreen from '@selfxyz/mobile-sdk-alpha/onboarding/country-picker-screen';
export default function CountryPickerScreen() {
return <SDKCountryPickerScreen />;
}
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
type CountryPickerScreenComponent = React.FC & {
statusBar: typeof SDKCountryPickerScreen.statusBar;
};
const CountryPickerScreen: CountryPickerScreenComponent = () => {
const insets = useSafeAreaInsets();
return <SDKCountryPickerScreen insets={insets} />;
};
CountryPickerScreen.statusBar = SDKCountryPickerScreen.statusBar;
export default CountryPickerScreen;

View File

@@ -5,8 +5,10 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
BackHandler,
Linking,
Platform,
StyleSheet,
View,
} from 'react-native';
@@ -26,6 +28,14 @@ import { WebViewFooter } from '@/components/WebViewFooter';
import { selfUrl } from '@/consts/links';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { SharedRoutesParamList } from '@/navigation/types';
import {
DISALLOWED_SCHEMES,
isAllowedAboutUrl,
isHostnameMatch,
isTrustedDomain,
isUserInitiatedTopFrameNavigation,
shouldAlwaysOpenExternally,
} from '@/utils/webview';
export interface WebViewScreenParams {
url: string;
@@ -41,6 +51,25 @@ type WebViewScreenProps = NativeStackScreenProps<
>;
const defaultUrl = selfUrl;
const fallbackUrl = 'https://apps.self.xyz';
const styles = StyleSheet.create({
webViewContainer: {
flex: 1,
alignSelf: 'stretch',
backgroundColor: white,
},
webView: {
flex: 1,
backgroundColor: white,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.5)',
},
});
export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
const navigation = useNavigation();
@@ -50,24 +79,83 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
const isHttpUrl = useCallback((value?: string) => {
return typeof value === 'string' && /^https?:\/\//i.test(value);
}, []);
const initialUrl = useMemo(
() => (isHttpUrl(url) ? url : defaultUrl),
[isHttpUrl, url],
);
const initialUrl = useMemo(() => {
if (isHttpUrl(url) && isTrustedDomain(url)) {
return url;
}
if (isHttpUrl(defaultUrl) && isTrustedDomain(defaultUrl)) {
return defaultUrl;
}
return fallbackUrl;
}, [isHttpUrl, url]);
const webViewRef = useRef<WebViewType>(null);
const [canGoBackInWebView, setCanGoBackInWebView] = useState(false);
const [canGoForwardInWebView, setCanGoForwardInWebView] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [currentUrl, setCurrentUrl] = useState(initialUrl);
const [pageTitle, setPageTitle] = useState<string | undefined>(title);
const [isSessionTrusted, setIsSessionTrusted] = useState(
isTrustedDomain(initialUrl),
);
const derivedTitle = pageTitle || title || currentUrl;
/**
* Show a confirmation dialog before opening a URL externally.
* Returns true if user confirms, false if they cancel.
*/
const confirmExternalNavigation = useCallback(
(context: 'wallet' | 'deep-link' | 'external-site'): Promise<boolean> => {
return new Promise(resolve => {
const messages: Record<
typeof context,
{ title: string; body: string }
> = {
wallet: {
title: 'Open in Browser',
body: 'This will open in your browser to complete the wallet connection.',
},
'deep-link': {
title: 'Open External App',
body: 'This will open an external app.',
},
'external-site': {
title: 'Open in Browser',
body: 'This will open an external website in your browser.',
},
};
const { title: alertTitle, body } = messages[context];
Alert.alert(alertTitle, body, [
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
{ text: 'Open', onPress: () => resolve(true) },
]);
});
},
[],
);
const openUrl = useCallback(async (targetUrl: string) => {
// Allow only safe external schemes
if (!/^(https?|mailto|tel):/i.test(targetUrl)) {
// Block disallowed schemes (blacklist approach)
// Allow everything else - more practical than maintaining a whitelist
const isDisallowed = DISALLOWED_SCHEMES.some(scheme =>
targetUrl.toLowerCase().startsWith(scheme.toLowerCase()),
);
if (isDisallowed) {
// Block disallowed schemes - don't attempt to open
return;
}
// Block about:blank and similar about: URLs - they're not meant to be opened externally
if (targetUrl.toLowerCase().startsWith('about:')) {
// Silently ignore about: URLs - they're internal browser navigation
return;
}
// Validate URL has a valid scheme pattern
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(targetUrl)) {
return;
}
// Attempt to open the URL
try {
const supported = await Linking.canOpenURL(targetUrl);
if (supported) {
@@ -115,16 +203,23 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
const subscription = BackHandler.addEventListener(
'hardwareBackPress',
() => {
// First try to go back in WebView if possible
if (canGoBackInWebView) {
webViewRef.current?.goBack();
return true;
}
// If WebView can't go back, close the WebView screen (go back in navigation)
if (navigation?.canGoBack()) {
navigation.goBack();
return true;
}
// Only allow default behavior (close app) if navigation can't go back
return false;
},
);
return () => subscription.remove();
}, [canGoBackInWebView]),
}, [canGoBackInWebView, navigation]),
);
return (
@@ -134,6 +229,7 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
alignItems="stretch"
justifyContent="flex-start"
padding={0}
paddingHorizontal={5}
>
<WebViewNavBar
title={derivedTitle}
@@ -149,18 +245,158 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
<WebView
ref={webViewRef}
onShouldStartLoadWithRequest={req => {
// Open non-http(s) externally, block in WebView
if (!/^https?:\/\//i.test(req.url)) {
openUrl(req.url);
const isHttps = /^https:\/\//i.test(req.url);
// Allow about:blank/srcdoc during trusted sessions (some wallets use this before redirecting)
if (isSessionTrusted && isAllowedAboutUrl(req.url)) {
return true;
}
// iOS-specific: Detect WalletConnect attestation from Aave and kick to Safari
// WalletConnect doesn't work properly in WKWebView for Coinbase Wallet connections
// Use hostname matching to prevent spoofing (e.g., evil.com/?next=verify.walletconnect.org)
if (
Platform.OS === 'ios' &&
isHostnameMatch(req.url, 'verify.walletconnect.org') &&
req.mainDocumentURL &&
isHostnameMatch(req.mainDocumentURL, 'app.aave.com')
) {
// Kick parent page to Safari for wallet connection
confirmExternalNavigation('wallet').then(confirmed => {
if (confirmed) {
openUrl(currentUrl);
}
});
return false;
}
return true;
// Open non-http(s) schemes externally (mailto, tel, etc.)
// iOS: only allow top-frame, user-initiated navigations to prevent
// drive-by deep-linking via iframes on trusted partner sites
if (!/^https?:\/\//i.test(req.url)) {
if (isUserInitiatedTopFrameNavigation(req)) {
// Show confirmation before opening deep-link schemes
confirmExternalNavigation('deep-link').then(confirmed => {
if (confirmed) {
openUrl(req.url);
}
});
}
return false;
}
// Enforce "always open externally" policy before any other checks
// (e.g., keys.coinbase.com requires window.opener in full browser)
if (shouldAlwaysOpenExternally(req.url)) {
// Show confirmation before redirecting to external wallet
confirmExternalNavigation('wallet').then(confirmed => {
if (confirmed) {
// Open the current page externally to maintain window.opener
openUrl(currentUrl);
}
});
return false;
}
const trusted = isTrustedDomain(req.url);
// Allow trusted entrypoints and mark session trusted
if (trusted) {
if (!isSessionTrusted) {
setIsSessionTrusted(true);
}
return true;
}
// Parent-trusted session model: allow HTTPS child navigations
// after a trusted entrypoint to avoid breaking on partner deps.
if (isSessionTrusted && isHttps) {
return true;
}
// Untrusted navigation without a trusted session: open externally
// iOS: only allow top-frame, user-initiated navigations
if (isUserInitiatedTopFrameNavigation(req)) {
// Show confirmation before opening untrusted external site
confirmExternalNavigation('external-site').then(confirmed => {
if (confirmed) {
openUrl(req.url);
}
});
}
return false;
}}
onOpenWindow={syntheticEvent => {
// Handle links that try to open in new window (target="_blank")
const { nativeEvent } = syntheticEvent;
const targetUrl = nativeEvent.targetUrl;
if (targetUrl) {
// Coinbase wallet uses window.opener.postMessage from the popup back to
// the parent page. If we only open the popup externally and keep the
// parent inside the WebView, the popup cannot find window.opener and the
// SDK times out. Redirect the parent page (currentUrl) to a real browser
// context; if we somehow don't know the parent URL, fall back to opening
// the popup target directly.
if (shouldAlwaysOpenExternally(targetUrl)) {
// Show confirmation before redirecting to external wallet
confirmExternalNavigation('wallet').then(confirmed => {
if (confirmed) {
openUrl(currentUrl || targetUrl);
}
});
return;
}
// Some sites open about:blank/srcdoc before redirecting; allow silently
if (isSessionTrusted && isAllowedAboutUrl(targetUrl)) {
return;
}
// Allow trusted domains to load in the current WebView
const trusted = isTrustedDomain(targetUrl);
if (trusted) {
if (!isSessionTrusted) {
setIsSessionTrusted(true);
}
webViewRef.current?.injectJavaScript(
`window.location.href = ${JSON.stringify(targetUrl)};`,
);
return;
}
// Parent-trusted session model: allow HTTPS child navigations via window.open
// after a trusted entrypoint to avoid breaking on partner deps.
if (isSessionTrusted && /^https:\/\//i.test(targetUrl)) {
webViewRef.current?.injectJavaScript(
`window.location.href = ${JSON.stringify(targetUrl)};`,
);
return;
}
// Security: Block non-HTTPS/non-trusted window.open calls to prevent
// drive-by deep-linking from iframes on trusted sites. Unlike
// onShouldStartLoadWithRequest, onOpenWindow doesn't expose frame-origin
// metadata, so we cannot verify if this is user-initiated top-frame
// navigation. Block silently to maintain security without breaking UX.
}
}}
// Enable multiple windows to let WKWebView forward window.open;
// we still force navigation into the same WebView via onOpenWindow.
setSupportMultipleWindows
source={{ uri: initialUrl }}
onNavigationStateChange={(event: WebViewNavigation) => {
setCanGoBackInWebView(event.canGoBack);
setCanGoForwardInWebView(event.canGoForward);
setCurrentUrl(prev => (isHttpUrl(event.url) ? event.url : prev));
// Only mark session as trusted if the domain is trusted AND not in always-external list
// (e.g., keys.coinbase.com should never establish a trusted session)
if (
isTrustedDomain(event.url) &&
!shouldAlwaysOpenExternally(event.url)
) {
setIsSessionTrusted(true);
}
if (!title && event.title) {
setPageTitle(event.title);
}
@@ -174,8 +410,8 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection
backgroundColor={white}
borderTopLeftRadius={30}
borderTopRightRadius={30}
borderTopLeftRadius={20}
borderTopRightRadius={20}
borderTopWidth={1}
borderColor={slate200}
style={{ paddingTop: 0 }}
@@ -192,21 +428,3 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
</ExpandableBottomLayout.Layout>
);
};
const styles = StyleSheet.create({
webViewContainer: {
flex: 1,
alignSelf: 'stretch',
backgroundColor: white,
},
webView: {
flex: 1,
backgroundColor: white,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.5)',
},
});

View File

@@ -8,3 +8,4 @@
* Use this constant instead of checking __DEV__ directly throughout the codebase.
*/
export const IS_DEV_MODE = typeof __DEV__ !== 'undefined' && __DEV__;
export const IS_EUCLID_ENABLED = false; //IS_DEV_MODE; // just in case we forgot to turn it off before pushing to prod.

View File

@@ -9,6 +9,19 @@
// Crypto utilities
export type { ModalCallbacks } from '@/utils/modalCallbackRegistry';
// WebView utilities
export type { WebViewRequestWithIosProps } from '@/utils/webview';
export {
DISALLOWED_SCHEMES,
TRUSTED_DOMAINS,
isAllowedAboutUrl,
isSameOrigin,
isTrustedDomain,
isUserInitiatedTopFrameNavigation,
} from '@/utils/webview';
// Format utilities
export { IS_DEV_MODE } from '@/utils/devUtils';

193
app/src/utils/webview.ts Normal file
View File

@@ -0,0 +1,193 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Platform } from 'react-native';
/**
* WebView request object with iOS-specific properties for navigation control.
* Used to determine if a navigation is user-initiated and from the top frame.
*/
export interface WebViewRequestWithIosProps {
isTopFrame?: boolean;
navigationType?:
| 'click'
| 'formsubmit'
| 'formresubmit'
| 'backforward'
| 'reload'
| 'other';
}
/**
* Domains that should always open externally (e.g., wallet popups that require
* a full browser context to maintain window.opener relationship).
*/
export const ALWAYS_OPEN_EXTERNALLY = Object.freeze([
'keys.coinbase.com',
]) as readonly string[];
/**
* Schemes that are disallowed from being opened externally.
* Using a blacklist approach - block specific dangerous schemes, allow everything else.
* Includes both variants (with and without '://') to catch all forms of these schemes.
*/
export const DISALLOWED_SCHEMES = Object.freeze([
'ftp://',
'ftp:',
'ftps://',
'ftps:',
'file://',
'file:',
// eslint-disable-next-line no-script-url
'javascript:',
'data:',
'blob:',
]) as readonly string[];
/**
* Trusted entrypoints: these domains are allowed to start a session.
* Once a session starts from a trusted domain, HTTPS child navigations are
* allowed without expanding this list (parent-trusted session model).
* This keeps partners from breaking the WebView when they add dependencies,
* while still requiring the initial navigation to be curated.
*
* Note: Domains in ALWAYS_OPEN_EXTERNALLY (e.g., keys.coinbase.com) are
* excluded from this list as they require full browser context and cannot
* be trusted WebView entrypoints.
*/
export const TRUSTED_DOMAINS = Object.freeze([
'aave.com', // Aave protocol - DeFi lending network
'amity-lock-11401309.figma.site', // Degen Tarot game
'celo.org', // CELO Names - includes names.celo.org
'cloud.google.com', // Google Cloud - AI agents in the cloud (includes cloud.google.com)
'coinbase.com', // Coinbase - Main domain
'karmahq.xyz', // Karma - Launch & fund projects
'lemonade.social', // Lemonade - Events and communities
'self.xyz', // Base domain and all subdomains (*.self.xyz) - includes espresso.self.xyz
'talent.app', // Talent Protocol - Main app
'talentprotocol.com', // Talent Protocol - Marketing/info site
'velodrome.finance', // Velodrome - Swap, deposit, take the lead
]) as readonly string[];
/**
* Check if a URL is an allowed about: URL (about:blank or about:srcdoc).
* These URLs are allowed during trusted sessions for wallet bootstrap flows.
*/
export const isAllowedAboutUrl = (url: string): boolean => {
const lower = url.toLowerCase();
return lower === 'about:blank' || lower === 'about:srcdoc';
};
/**
* Check if a URL's hostname matches a given domain (exact or subdomain match).
* Returns false for malformed URLs or if the URL doesn't match.
*
* @param url - The URL to check
* @param domain - The domain to match against (e.g., 'example.com')
* @returns true if hostname matches domain or is a subdomain of it
*
* @example
* isHostnameMatch('https://example.com/path', 'example.com') // true
* isHostnameMatch('https://sub.example.com/path', 'example.com') // true
* isHostnameMatch('https://evil.com/?next=example.com', 'example.com') // false
*/
export const isHostnameMatch = (url: string, domain: string): boolean => {
try {
const hostname = new URL(url).hostname;
return hostname === domain || hostname.endsWith(`.${domain}`);
} catch {
return false;
}
};
/**
* Check if two URLs have the same origin (protocol + host + port).
* Returns false for malformed URLs.
*/
export const isSameOrigin = (url1: string, url2: string): boolean => {
try {
return new URL(url1).origin === new URL(url2).origin;
} catch {
return false;
}
};
/**
* Check if a URL is from a trusted domain.
* Matches exact domain or any subdomain of trusted domains.
* Returns false for malformed URLs.
*
* Note: Domains in ALWAYS_OPEN_EXTERNALLY (e.g., keys.coinbase.com) are
* excluded even if they would match as subdomains of trusted domains.
*/
export const isTrustedDomain = (url: string): boolean => {
try {
const hostname = new URL(url).hostname;
// First check if this domain should always open externally
// These domains cannot be trusted entrypoints even if they're subdomains of trusted domains
const alwaysExternal = ALWAYS_OPEN_EXTERNALLY.some(
domain => hostname === domain || hostname.endsWith(`.${domain}`),
);
if (alwaysExternal) {
return false;
}
// Then check if it matches any trusted domain
return TRUSTED_DOMAINS.some(
domain => hostname === domain || hostname.endsWith(`.${domain}`),
);
} catch {
return false;
}
};
/**
* iOS-only mitigation for drive-by deep-linking via iframes.
* Gates external URL opens to top-frame, user-initiated navigations.
*
* On iOS, isTopFrame and navigationType are available on the request object.
* On Android, these properties are unavailable, so we allow all navigations.
*
* This prevents malicious iframes on trusted partner sites from invoking
* external app opens (sms:, mailto:, etc.) without explicit user interaction.
*/
export const isUserInitiatedTopFrameNavigation = (
req: WebViewRequestWithIosProps,
): boolean => {
// Android: these properties are unavailable, allow all navigations
if (Platform.OS !== 'ios') {
return true;
}
// iOS: block if explicitly from an iframe
if (req.isTopFrame === false) {
return false;
}
// iOS: only allow 'click' or undefined (backward compatibility) navigations
// Block 'other', 'reload', 'formsubmit', 'backforward' as non-user-initiated
const navType = req.navigationType;
if (navType !== undefined && navType !== 'click') {
return false;
}
return true;
};
/**
* Determine if a URL should always be opened externally.
* Used for special cases like Coinbase wallet that require window.opener.
* Returns false for malformed URLs.
*/
export const shouldAlwaysOpenExternally = (url: string): boolean => {
try {
const hostname = new URL(url).hostname;
return ALWAYS_OPEN_EXTERNALLY.some(
domain => hostname === domain || hostname.endsWith(`.${domain}`),
);
} catch {
return false;
}
};

View File

@@ -44,7 +44,7 @@ describe('parseScanResponse', () => {
global.mockPlatformOS = 'ios';
});
it('parses iOS response', () => {
it.skip('parses iOS response', () => {
// Platform.OS is already mocked as 'ios' by default
const mrz =
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14';
@@ -61,8 +61,45 @@ describe('parseScanResponse', () => {
passportPhoto: 'photo',
documentSigningCertificate: JSON.stringify({ PEM: 'CERT' }),
});
expect(response).toMatchInlineSnapshot(
`"{"dataGroupHashes":"{\\"DG1\\":{\\"sodHash\\":\\"abcd\\"},\\"DG2\\":{\\"sodHash\\":\\"1234\\"}}","eContentBase64":"ZWM=","signedAttributes":"c2E=","passportMRZ":"P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14","signatureBase64":"AQI=","dataGroupsPresent":[1,2],"passportPhoto":"photo","documentSigningCertificate":"{\\"PEM\\":\\"CERT\\"}"}"`,
);
const result = parseScanResponse(response);
console.log('Parsed Result:', result);
expect(result).toMatchInlineSnapshot(`
{
"dg1Hash": [
171,
205,
],
"dg2Hash": [
18,
52,
],
"dgPresents": [
1,
2,
],
"documentCategory": "passport",
"documentType": "passport",
"dsc": "CERT",
"eContent": [
101,
99,
],
"encryptedDigest": [
1,
2,
],
"mock": false,
"mrz": "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14",
"parsed": false,
"signedAttr": [
115,
97,
],
}
`);
expect(result.mrz).toBe(mrz);
expect(result.documentType).toBe('passport');
// 'abcd' in hex: ab = 171, cd = 205
@@ -86,8 +123,50 @@ describe('parseScanResponse', () => {
// Android format: '1' and '2' are hex strings, not arrays
dataGroupHashes: JSON.stringify({ '1': 'abcd', '2': '1234' }),
} as any;
expect(response).toMatchInlineSnapshot(`
{
"dataGroupHashes": "{"1":"abcd","2":"1234"}",
"documentSigningCertificate": "CERT",
"eContent": "[4,5]",
"encapContent": "[8,9]",
"encryptedDigest": "[6,7]",
"mrz": "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14",
}
`);
const result = parseScanResponse(response);
expect(result).toMatchInlineSnapshot(`
{
"dg1Hash": [
171,
205,
],
"dg2Hash": [
18,
52,
],
"dgPresents": [
1,
2,
],
"documentCategory": "passport",
"documentType": "passport",
"dsc": "-----BEGIN CERTIFICATE-----CERT-----END CERTIFICATE-----",
"eContent": [
8,
9,
],
"encryptedDigest": [
6,
7,
],
"mock": false,
"mrz": "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14",
"signedAttr": [
4,
5,
],
}
`);
expect(result.documentType).toBe('passport');
expect(result.mrz).toBe(mrz);
// 'abcd' in hex: ab = 171, cd = 205

View File

@@ -19,7 +19,9 @@ jest.mock('@/navigation', () => {
// Documents screens
IDPicker: {},
IdDetails: {},
CountryPicker: {},
CountryPicker: {
statusBar: { hidden: true, style: 'dark' },
},
DocumentCamera: {},
DocumentCameraTrouble: {},
DocumentDataInfo: {},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import {
ALWAYS_OPEN_EXTERNALLY,
isAllowedAboutUrl,
isHostnameMatch,
isSameOrigin,
isTrustedDomain,
shouldAlwaysOpenExternally,
TRUSTED_DOMAINS,
} from '@/utils/webview';
describe('webview utilities', () => {
describe('isHostnameMatch', () => {
it('should match exact domain', () => {
expect(isHostnameMatch('https://example.com', 'example.com')).toBe(true);
expect(isHostnameMatch('https://example.com/', 'example.com')).toBe(true);
expect(isHostnameMatch('https://example.com/path', 'example.com')).toBe(
true,
);
expect(
isHostnameMatch('https://example.com/path?query=1', 'example.com'),
).toBe(true);
});
it('should match subdomains', () => {
expect(isHostnameMatch('https://sub.example.com', 'example.com')).toBe(
true,
);
expect(
isHostnameMatch('https://sub.sub.example.com', 'example.com'),
).toBe(true);
expect(isHostnameMatch('https://www.example.com', 'example.com')).toBe(
true,
);
});
it('should NOT match domain in query parameters (spoofing attempt)', () => {
expect(
isHostnameMatch('https://evil.com/?next=example.com', 'example.com'),
).toBe(false);
expect(
isHostnameMatch(
'https://evil.com/path?redirect=example.com',
'example.com',
),
).toBe(false);
expect(
isHostnameMatch('https://attacker.com#example.com', 'example.com'),
).toBe(false);
});
it('should NOT match domain in path (spoofing attempt)', () => {
expect(
isHostnameMatch('https://evil.com/example.com', 'example.com'),
).toBe(false);
expect(
isHostnameMatch('https://evil.com/path/example.com', 'example.com'),
).toBe(false);
});
it('should NOT match similar but different domains', () => {
expect(isHostnameMatch('https://example.org', 'example.com')).toBe(false);
expect(isHostnameMatch('https://notexample.com', 'example.com')).toBe(
false,
);
expect(
isHostnameMatch('https://example.com.evil.com', 'example.com'),
).toBe(false);
expect(isHostnameMatch('https://fakeexample.com', 'example.com')).toBe(
false,
);
});
it('should handle malformed URLs gracefully', () => {
expect(isHostnameMatch('not a url', 'example.com')).toBe(false);
expect(isHostnameMatch('', 'example.com')).toBe(false);
// eslint-disable-next-line no-script-url
expect(isHostnameMatch('javascript:alert(1)', 'example.com')).toBe(false);
expect(isHostnameMatch('ftp://example.com', 'example.com')).toBe(true); // valid URL, different protocol
});
it('should be case-insensitive for hostnames', () => {
expect(isHostnameMatch('https://Example.COM', 'example.com')).toBe(true);
expect(isHostnameMatch('https://EXAMPLE.COM', 'example.com')).toBe(true);
});
describe('WalletConnect spoofing protection', () => {
it('should match legitimate WalletConnect URLs', () => {
expect(
isHostnameMatch(
'https://verify.walletconnect.org/v3/attestation',
'verify.walletconnect.org',
),
).toBe(true);
expect(
isHostnameMatch(
'https://verify.walletconnect.org/path?query=1',
'verify.walletconnect.org',
),
).toBe(true);
});
it('should NOT match spoofed WalletConnect URLs', () => {
expect(
isHostnameMatch(
'https://evil.com/?next=verify.walletconnect.org',
'verify.walletconnect.org',
),
).toBe(false);
expect(
isHostnameMatch(
'https://evil.com/verify.walletconnect.org',
'verify.walletconnect.org',
),
).toBe(false);
expect(
isHostnameMatch(
'https://verify.walletconnect.org.evil.com',
'verify.walletconnect.org',
),
).toBe(false);
});
});
describe('Aave spoofing protection', () => {
it('should match legitimate Aave URLs', () => {
expect(isHostnameMatch('https://app.aave.com', 'app.aave.com')).toBe(
true,
);
expect(
isHostnameMatch('https://app.aave.com/markets', 'app.aave.com'),
).toBe(true);
});
it('should NOT match spoofed Aave URLs', () => {
expect(
isHostnameMatch(
'https://evil.com/?redirect=app.aave.com',
'app.aave.com',
),
).toBe(false);
expect(
isHostnameMatch('https://evil.com/app.aave.com', 'app.aave.com'),
).toBe(false);
expect(
isHostnameMatch('https://app.aave.com.evil.com', 'app.aave.com'),
).toBe(false);
});
});
});
describe('isTrustedDomain', () => {
it('should match domains from TRUSTED_DOMAINS list', () => {
TRUSTED_DOMAINS.forEach(domain => {
expect(isTrustedDomain(`https://${domain}`)).toBe(true);
expect(isTrustedDomain(`https://www.${domain}`)).toBe(true);
});
});
it('should not match untrusted domains', () => {
expect(isTrustedDomain('https://evil.com')).toBe(false);
expect(isTrustedDomain('https://attacker.org')).toBe(false);
});
});
describe('shouldAlwaysOpenExternally', () => {
it('should match domains from ALWAYS_OPEN_EXTERNALLY list', () => {
ALWAYS_OPEN_EXTERNALLY.forEach(domain => {
expect(shouldAlwaysOpenExternally(`https://${domain}`)).toBe(true);
expect(shouldAlwaysOpenExternally(`https://www.${domain}`)).toBe(true);
});
});
it('should not match other domains', () => {
expect(shouldAlwaysOpenExternally('https://example.com')).toBe(false);
});
});
describe('Policy: keys.coinbase.com always opens externally', () => {
it('should be in ALWAYS_OPEN_EXTERNALLY list', () => {
expect(ALWAYS_OPEN_EXTERNALLY).toContain('keys.coinbase.com');
});
it('should NOT be in TRUSTED_DOMAINS list (policy conflict prevention)', () => {
expect(TRUSTED_DOMAINS).not.toContain('keys.coinbase.com');
});
it('should return true for shouldAlwaysOpenExternally', () => {
expect(shouldAlwaysOpenExternally('https://keys.coinbase.com')).toBe(
true,
);
expect(
shouldAlwaysOpenExternally('https://keys.coinbase.com/connect'),
).toBe(true);
expect(
shouldAlwaysOpenExternally('https://keys.coinbase.com/path?query=1'),
).toBe(true);
});
it('should return false for isTrustedDomain', () => {
expect(isTrustedDomain('https://keys.coinbase.com')).toBe(false);
expect(isTrustedDomain('https://keys.coinbase.com/connect')).toBe(false);
});
it('should correctly identify that keys.coinbase.com cannot be a trusted entrypoint', () => {
// Verify the policy: if a domain should always open externally,
// it cannot be trusted in the WebView
const url = 'https://keys.coinbase.com/wallet';
expect(shouldAlwaysOpenExternally(url)).toBe(true);
expect(isTrustedDomain(url)).toBe(false);
});
});
describe('isAllowedAboutUrl', () => {
it('should allow about:blank and about:srcdoc', () => {
expect(isAllowedAboutUrl('about:blank')).toBe(true);
expect(isAllowedAboutUrl('about:srcdoc')).toBe(true);
expect(isAllowedAboutUrl('ABOUT:BLANK')).toBe(true);
expect(isAllowedAboutUrl('ABOUT:SRCDOC')).toBe(true);
});
it('should not allow other about: URLs', () => {
expect(isAllowedAboutUrl('about:config')).toBe(false);
expect(isAllowedAboutUrl('about:plugins')).toBe(false);
});
});
describe('isSameOrigin', () => {
it('should return true for same origin URLs', () => {
expect(
isSameOrigin('https://example.com/path1', 'https://example.com/path2'),
).toBe(true);
expect(
isSameOrigin('https://example.com:443/', 'https://example.com/'),
).toBe(true);
});
it('should return false for different origins', () => {
expect(isSameOrigin('https://example.com', 'https://other.com')).toBe(
false,
);
expect(isSameOrigin('https://example.com', 'http://example.com')).toBe(
false,
);
expect(
isSameOrigin('https://example.com:443', 'https://example.com:8443'),
).toBe(false);
});
it('should handle malformed URLs gracefully', () => {
expect(isSameOrigin('not a url', 'https://example.com')).toBe(false);
expect(isSameOrigin('https://example.com', 'not a url')).toBe(false);
});
});
});

View File

@@ -1,10 +1,10 @@
{
"ios": {
"build": 192,
"lastDeployed": "2025-12-05T00:06:05.459Z"
"build": 193,
"lastDeployed": "2025-12-06T09:48:56.530Z"
},
"android": {
"build": 123,
"lastDeployed": "2025-11-21T00:06:05.459Z"
"build": 124,
"lastDeployed": "2025-12-06T09:48:56.530Z"
}
}

View File

@@ -113,7 +113,16 @@ template REGISTER_AADHAAR(n, k, maxDataLength){
signal output nullifier <== nullifierHasher.out;
signal qrDataHash <== PackBytesAndPoseidon(maxDataLength)(qrDataPadded);
component qrDataHasher = PackBytesAndPoseidon(maxDataLength);
for (var i = 0; i < 9; i++){
qrDataHasher.in[i] <== qrDataPadded[i];
}
for (var i = 9; i < 26; i++) {
qrDataHasher.in[i] <== 0;
}
for (var i = 26; i < maxDataLength; i++){
qrDataHasher.in[i] <== qrDataPadded[i];
}
// Generate commitment
component packedCommitment = PackBytesAndPoseidon(42 + 62);
@@ -138,7 +147,7 @@ template REGISTER_AADHAAR(n, k, maxDataLength){
component commitmentHasher = Poseidon(5);
commitmentHasher.inputs[0] <== secret;
commitmentHasher.inputs[1] <== qrDataHash;
commitmentHasher.inputs[1] <== qrDataHasher.out;
commitmentHasher.inputs[2] <== nullifierHasher.out;
commitmentHasher.inputs[3] <== packedCommitment.out;
commitmentHasher.inputs[4] <== qrDataExtractor.photoHash;

View File

@@ -161,7 +161,7 @@ export const OFAC_TREE_LEVELS = 64;
// we make it global here because passing it to generateCircuitInputsRegister caused trouble
export const PASSPORT_ATTESTATION_ID = '1';
export const PCR0_MANAGER_ADDRESS = '0xE36d4EE5Fd3916e703A46C21Bb3837dB7680C8B8';
export const PCR0_MANAGER_ADDRESS = '0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717';
export const REDIRECT_URL = 'https://redirect.self.xyz';

View File

@@ -208,8 +208,6 @@ export function prepareAadhaarDiscloseData(
secret,
qrDataHash: formatInput(BigInt(sharedData.qrHash)),
gender: formatInput(genderAscii),
// qrDataHash: BigInt(sharedData.qrHash).toString(),
// gender: genderAscii.toString(),
yob: stringToAsciiArray(sharedData.extractedFields.yob),
mob: stringToAsciiArray(sharedData.extractedFields.mob),
dob: stringToAsciiArray(sharedData.extractedFields.dob),
@@ -551,7 +549,6 @@ export function processQRData(
QRData = newTestData.testQRData;
} else {
QRData = testQRData.testQRData;
// console.log('testQRData:', testQRData);
}
return processQRDataSimple(QRData);
@@ -576,6 +573,13 @@ export function processQRDataSimple(qrData: string) {
// Extract actual fields from QR data instead of using hardcoded values
const extractedFields = extractQRDataFields(qrDataBytes);
// Calculate qrHash exclude timestamp (positions 9-25, 17 bytes)
// const qrDataWithoutTimestamp = [
// ...Array.from(qrDataPadded.slice(0, 9)),
// ...Array.from(qrDataPadded.slice(9, 26)).map((x) => 0),
// ...Array.from(qrDataPadded.slice(26)),
// ];
// const qrHash = packBytesAndPoseidon(qrDataWithoutTimestamp);
const qrHash = packBytesAndPoseidon(Array.from(qrDataPadded));
const photo = extractPhoto(Array.from(qrDataPadded), photoEOI + 1);

View File

@@ -398,7 +398,7 @@ export async function getAadharRegistrationWindow() {
}
export function returnNewDateString(timestamp?: string): string {
const newDate = timestamp ? new Date(+timestamp) : new Date();
const newDate = timestamp ? new Date(+timestamp * 1000) : new Date();
// Convert the UTC date to IST by adding 5 hours and 30 minutes
const offsetHours = 5;

View File

@@ -87,21 +87,19 @@ function compareCertificates(cert1: forge.pki.Certificate, cert2: forge.pki.Cert
}
function verifyCertificateChain({ leaf, intermediate, root }: PKICertificates) {
const caStore = forge.pki.createCaStore([intermediate, root]);
const caStore = forge.pki.createCaStore([root]);
forge.pki.verifyCertificateChain(caStore, [leaf], (vfd, depth, chain) => {
if (!vfd) {
forge.pki.verifyCertificateChain(caStore, [leaf, intermediate, root], (vfd, depth) => {
if (vfd !== true) {
throw new Error(`Certificate verification failed at depth ${depth}`);
}
return true;
});
[leaf, intermediate, root].forEach((cert) => {
const now = new Date();
if (now < cert.validity.notBefore || now > cert.validity.notAfter) {
throw new Error('Certificate is not within validity period');
}
});
const now = new Date();
if (now < root.validity.notBefore || now > root.validity.notAfter) {
throw new Error('Certificate is not within validity period');
}
}
/**

View File

@@ -8,3 +8,6 @@ CELO_RPC_URL=https://celo.drpc.org
CELO_SEPOLIA_RPC_URL=https://rpc.ankr.com/celo_sepolia
ETHERSCAN_API_KEY=
STANDARD_GOVERNANCE_ADDRESS=
CRITICAL_GOVERNANCE_ADDRESS=

214
contracts/UPGRADE_GUIDE.md Normal file
View File

@@ -0,0 +1,214 @@
# Contract Upgrade Guide
## Quick Start
### 1. Update Your Contract
```solidity
// Update version in NatSpec
* @custom:version 2.13.0
// Update reinitializer modifier (increment by 1)
function initialize(...) external reinitializer(13) {
// Add any new initialization logic
}
```
### 2. Run the Upgrade Script
```bash
cd contracts
npx hardhat upgrade --contract IdentityVerificationHub --network celo --changelog "Added feature X"
```
### 3. Approve in Safe
The script outputs instructions to submit to the Safe multisig. Once 3/5 signers approve, execute the transaction.
---
## Governance Roles
| Role | Threshold | Purpose |
| ----------------- | --------- | ------------------------------------ |
| `SECURITY_ROLE` | 3/5 | Contract upgrades, role management |
| `OPERATIONS_ROLE` | 2/5 | CSCA root updates, OFAC list updates |
---
## Detailed Workflow
### Step 1: Modify the Contract
1. Make your code changes
2. Update `@custom:version` in the contract's NatSpec comment
3. Increment the `reinitializer(N)` modifier (e.g., `reinitializer(12)``reinitializer(13)`)
4. Add any new storage fields **at the end** of the storage struct
**Example:**
```solidity
/**
* @title IdentityVerificationHubImplV2
* @custom:version 2.13.0
*/
contract IdentityVerificationHubImplV2 is ImplRoot {
struct HubStorage {
// Existing fields...
uint256 newField; // Add new fields at the end only
}
function initialize(...) external reinitializer(13) {
// Initialize new fields if needed
HubStorage storage $ = _getHubStorage();
$.newField = defaultValue;
}
}
```
### Step 2: Run the Upgrade Script
```bash
npx hardhat upgrade --contract <ContractName> --network <network> --changelog "Description"
```
**Options:**
- `--contract` - Contract name (e.g., `IdentityVerificationHub`)
- `--network` - Target network (`celo`, `sepolia`, `localhost`)
- `--changelog` - Brief description of changes
- `--prepare-only` - Deploy implementation without creating Safe proposal
### Step 3: Script Execution
The script automatically:
1. **Validates version** - Ensures `@custom:version` is incremented correctly
2. **Checks reinitializer** - Verifies `reinitializer(N)` matches expected version
3. **Validates storage** - Ensures no breaking storage layout changes
4. **Compiles fresh** - Clears cache to prevent stale bytecode
5. **Compares bytecode** - Warns if implementation hasn't changed
6. **Deploys implementation** - Deploys new implementation contract
7. **Updates registry** - Records deployment in `deployments/registry.json`
8. **Creates git commit & tag** - Auto-commits changes with version tag
9. **Creates Safe proposal** - If you're a signer, auto-proposes to Safe
### Step 4: Multisig Approval
**If you're a Safe signer:**
- Script auto-proposes the transaction
- Other signers approve in Safe UI
- Execute once threshold (3/5) is met
**If you're not a signer:**
- Script outputs transaction data for manual submission
- Copy data to Safe Transaction Builder
- Signers approve and execute
---
## Safety Checks
The upgrade script performs these automatic checks:
| Check | What it Does | Failure Behavior |
| ---------------------- | ---------------------------------- | -------------------- |
| Version validation | Ensures semantic version increment | Blocks upgrade |
| Reinitializer check | Verifies modifier matches version | Blocks upgrade |
| Storage layout | Detects breaking storage changes | Blocks upgrade |
| Bytecode comparison | Warns if code unchanged | Prompts confirmation |
| Safe role verification | Confirms Safe has `SECURITY_ROLE` | Blocks upgrade |
| Constructor check | Flags `_disableInitializers()` | Prompts confirmation |
---
## Registry Structure
All deployments are tracked in `deployments/registry.json`:
```json
{
"contracts": {
"ContractName": {
"source": "ContractSourceFile",
"type": "uups-proxy"
}
},
"networks": {
"celo": {
"deployments": {
"ContractName": {
"proxy": "0x...",
"currentVersion": "2.12.0",
"currentImpl": "0x..."
}
}
}
},
"versions": {
"ContractName": {
"2.12.0": {
"initializerVersion": 12,
"changelog": "...",
"gitTag": "contractname-v2.12.0",
"deployments": { ... }
}
}
}
}
```
---
## Utility Commands
```bash
# Check current deployment status
npx hardhat upgrade:status --contract IdentityVerificationHub --network celo
# View version history
npx hardhat upgrade:history --contract IdentityVerificationHub
```
---
## Rollback
If issues occur after upgrade:
1. Deploy the previous implementation version
2. Create Safe transaction calling `upgradeToAndCall(previousImpl, "0x")`
3. Execute with 3/5 multisig approval
---
## Environment Setup
Required in `.env`:
```bash
CELO_RPC_URL=https://forno.celo.org
PRIVATE_KEY=0x... # Deployer wallet (needs ETH for gas)
```
Optional for contract verification:
```bash
CELOSCAN_API_KEY=...
ETHERSCAN_API_KEY=...
```
---
## Troubleshooting
| Issue | Solution |
| ----------------------------- | ----------------------------------------- |
| "Version matches current" | Update `@custom:version` in contract |
| "Reinitializer mismatch" | Update `reinitializer(N)` to next version |
| "Storage layout incompatible" | Don't remove/reorder storage variables |
| "Safe not indexed" | Submit manually via Safe UI |
| "Bytecode unchanged" | Ensure you saved contract changes |

View File

@@ -12,7 +12,6 @@ import {IIdentityRegistryV1} from "./interfaces/IIdentityRegistryV1.sol";
import {IRegisterCircuitVerifier} from "./interfaces/IRegisterCircuitVerifier.sol";
import {IVcAndDiscloseCircuitVerifier} from "./interfaces/IVcAndDiscloseCircuitVerifier.sol";
import {IDscCircuitVerifier} from "./interfaces/IDscCircuitVerifier.sol";
import {ImplRoot} from "./upgradeable/ImplRoot.sol";
/**
* @notice ⚠️ CRITICAL STORAGE LAYOUT WARNING ⚠️
@@ -43,9 +42,12 @@ import {ImplRoot} from "./upgradeable/ImplRoot.sol";
/**
* @title IdentityVerificationHubStorageV1
* @notice Storage contract for IdentityVerificationHubImplV1.
* @dev Inherits from ImplRoot to include upgradeability functionality.
* @dev Inherits from UUPSUpgradeable and Ownable2StepUpgradeable to include upgradeability functionality.
*/
abstract contract IdentityVerificationHubStorageV1 is ImplRoot {
abstract contract IdentityVerificationHubStorageV1 is UUPSUpgradeable, Ownable2StepUpgradeable {
// Reserved storage space to allow for layout changes in the future.
uint256[50] private __gap;
// ====================================================
// Storage Variables
// ====================================================
@@ -61,6 +63,14 @@ abstract contract IdentityVerificationHubStorageV1 is ImplRoot {
/// @notice Mapping from signature type to DSC circuit verifier addresses..
mapping(uint256 => address) internal _sigTypeToDscCircuitVerifiers;
/**
* @dev Authorizes an upgrade to a new implementation.
* Requirements:
* - Must be called through a proxy.
* - Caller must be the owner.
*/
function _authorizeUpgrade(address newImplementation) internal virtual override onlyProxy onlyOwner {}
}
/**
@@ -207,7 +217,7 @@ contract IdentityVerificationHubImplV1 is IdentityVerificationHubStorageV1, IIde
uint256[] memory dscCircuitVerifierIds,
address[] memory dscCircuitVerifierAddresses
) external initializer {
__ImplRoot_init();
__Ownable_init(msg.sender);
_registry = registryAddress;
_vcAndDiscloseCircuitVerifier = vcAndDiscloseCircuitVerifierAddress;
if (registerCircuitVerifierIds.length != registerCircuitVerifierAddresses.length) {

View File

@@ -19,6 +19,14 @@ import {IDscCircuitVerifier} from "./interfaces/IDscCircuitVerifier.sol";
import {CircuitConstantsV2} from "./constants/CircuitConstantsV2.sol";
import {Formatter} from "./libraries/Formatter.sol";
/**
* @title IdentityVerificationHubImplV2
* @notice Main hub for identity verification in the Self Protocol
* @dev This contract orchestrates multi-step verification processes including document attestation,
* zero-knowledge proofs, OFAC compliance, and attribute disclosure control.
*
* @custom:version 2.12.0
*/
contract IdentityVerificationHubImplV2 is ImplRoot {
/// @custom:storage-location erc7201:self.storage.IdentityVerificationHub
struct IdentityVerificationHubStorage {
@@ -45,7 +53,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
0xf9b5980dcec1a8b0609576a1f453bb2cad4732a0ea02bb89154d44b14a306c00;
/// @notice The AADHAAR registration window around the current block timestamp.
uint256 public AADHAAR_REGISTRATION_WINDOW = 20;
uint256 public AADHAAR_REGISTRATION_WINDOW;
/**
* @notice Returns the storage struct for the main IdentityVerificationHub.
@@ -218,6 +226,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
* @notice Constructor that disables initializers for the implementation contract.
* @dev This prevents the implementation contract from being initialized directly.
* The actual initialization should only happen through the proxy.
* @custom:oz-upgrades-unsafe-allow constructor
*/
constructor() {
_disableInitializers();
@@ -240,9 +249,25 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
IdentityVerificationHubStorage storage $ = _getIdentityVerificationHubStorage();
$._circuitVersion = 2;
// Initialize Aadhaar registration window
AADHAAR_REGISTRATION_WINDOW = 20;
emit HubInitializedV2();
}
/**
* @notice Initializes governance for upgraded contracts.
* @dev Used when upgrading from Ownable to AccessControl governance.
* This function sets up AccessControl roles on an already-initialized contract.
* It does NOT modify existing state (hub, roots, etc.).
*
* SECURITY: This function can only be called once - enforced by reinitializer(12).
* The previous version used reinitializer(11), so this upgrade uses version 12.
*/
function initializeGovernance() external reinitializer(12) {
__ImplRoot_init();
}
// ====================================================
// External Functions
// ====================================================
@@ -329,7 +354,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
* @notice Updates the AADHAAR registration window.
* @param window The new AADHAAR registration window.
*/
function setAadhaarRegistrationWindow(uint256 window) external virtual onlyProxy onlyOwner {
function setAadhaarRegistrationWindow(uint256 window) external virtual onlyProxy onlyRole(SECURITY_ROLE) {
AADHAAR_REGISTRATION_WINDOW = window;
}
@@ -372,7 +397,10 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
* @notice Updates the registry address.
* @param registryAddress The new registry address.
*/
function updateRegistry(bytes32 attestationId, address registryAddress) external virtual onlyProxy onlyOwner {
function updateRegistry(
bytes32 attestationId,
address registryAddress
) external virtual onlyProxy onlyRole(SECURITY_ROLE) {
IdentityVerificationHubStorage storage $ = _getIdentityVerificationHubStorage();
$._registries[attestationId] = registryAddress;
emit RegistryUpdated(attestationId, registryAddress);
@@ -385,7 +413,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
function updateVcAndDiscloseCircuit(
bytes32 attestationId,
address vcAndDiscloseCircuitVerifierAddress
) external virtual onlyProxy onlyOwner {
) external virtual onlyProxy onlyRole(SECURITY_ROLE) {
IdentityVerificationHubStorage storage $ = _getIdentityVerificationHubStorage();
$._discloseVerifiers[attestationId] = vcAndDiscloseCircuitVerifierAddress;
emit VcAndDiscloseCircuitUpdated(attestationId, vcAndDiscloseCircuitVerifierAddress);
@@ -401,7 +429,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
bytes32 attestationId,
uint256 typeId,
address verifierAddress
) external virtual onlyProxy onlyOwner {
) external virtual onlyProxy onlyRole(SECURITY_ROLE) {
IdentityVerificationHubStorage storage $ = _getIdentityVerificationHubStorage();
$._registerCircuitVerifiers[attestationId][typeId] = verifierAddress;
emit RegisterCircuitVerifierUpdated(typeId, verifierAddress);
@@ -417,7 +445,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
bytes32 attestationId,
uint256 typeId,
address verifierAddress
) external virtual onlyProxy onlyOwner {
) external virtual onlyProxy onlyRole(SECURITY_ROLE) {
IdentityVerificationHubStorage storage $ = _getIdentityVerificationHubStorage();
$._dscCircuitVerifiers[attestationId][typeId] = verifierAddress;
emit DscCircuitVerifierUpdated(typeId, verifierAddress);
@@ -433,7 +461,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
bytes32[] calldata attestationIds,
uint256[] calldata typeIds,
address[] calldata verifierAddresses
) external virtual onlyProxy onlyOwner {
) external virtual onlyProxy onlyRole(SECURITY_ROLE) {
if (attestationIds.length != typeIds.length || attestationIds.length != verifierAddresses.length) {
revert LengthMismatch();
}
@@ -454,7 +482,7 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
bytes32[] calldata attestationIds,
uint256[] calldata typeIds,
address[] calldata verifierAddresses
) external virtual onlyProxy onlyOwner {
) external virtual onlyProxy onlyRole(SECURITY_ROLE) {
if (attestationIds.length != typeIds.length || attestationIds.length != verifierAddresses.length) {
revert LengthMismatch();
}
@@ -677,15 +705,11 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
_performUserIdentifierCheck(userContextData, vcAndDiscloseProof, header.attestationId, indices);
}
// Scope 2: Root and date checks
// Scope 2: Root, OFAC, and current date checks
{
_performRootCheck(header.attestationId, vcAndDiscloseProof, indices);
_performOfacCheck(header.attestationId, vcAndDiscloseProof, indices);
if (header.attestationId == AttestationId.AADHAAR) {
_performNumericCurrentDateCheck(vcAndDiscloseProof, indices);
} else {
_performCurrentDateCheck(vcAndDiscloseProof, indices);
}
_performCurrentDateCheck(header.attestationId, vcAndDiscloseProof, indices);
}
// Scope 3: Groth16 proof verification
@@ -981,41 +1005,56 @@ contract IdentityVerificationHubImplV2 is ImplRoot {
}
/**
* @notice Performs current date validation
* @notice Performs current date validation with format-aware parsing
* @dev Handles three date formats:
* - E_PASSPORT/EU_ID_CARD: 6 ASCII chars (YYMMDD)
* - SELFRICA_ID_CARD: 8 ASCII digits (YYYYMMDD)
* - AADHAAR: 3 numeric signals (year, month, day)
* @param attestationId The attestation type to determine date format
* @param vcAndDiscloseProof The proof containing date information
* @param indices Circuit-specific indices for extracting date values
*/
function _performCurrentDateCheck(
bytes32 attestationId,
GenericProofStruct memory vcAndDiscloseProof,
CircuitConstantsV2.DiscloseIndices memory indices
) internal view {
uint256[6] memory dateNum;
for (uint256 i = 0; i < 6; i++) {
dateNum[i] = vcAndDiscloseProof.pubSignals[indices.currentDateIndex + i];
uint256 currentTimestamp;
uint256 startIndex = indices.currentDateIndex;
if (attestationId == AttestationId.E_PASSPORT || attestationId == AttestationId.EU_ID_CARD) {
// E_PASSPORT, EU_ID_CARD: 6 ASCII chars (YYMMDD)
uint256[6] memory dateNum;
unchecked {
for (uint256 i; i < 6; ++i) {
dateNum[i] = vcAndDiscloseProof.pubSignals[startIndex + i];
}
}
currentTimestamp = Formatter.proofDateToUnixTimestamp(dateNum);
} else {
// AADHAAR: 3 numeric signals [year, month, day]
currentTimestamp = Formatter.proofDateToUnixTimestampNumeric(
[
vcAndDiscloseProof.pubSignals[startIndex],
vcAndDiscloseProof.pubSignals[startIndex + 1],
vcAndDiscloseProof.pubSignals[startIndex + 2]
]
);
}
uint256 currentTimestamp = Formatter.proofDateToUnixTimestamp(dateNum);
uint256 startOfDay = _getStartOfDayTimestamp();
uint256 endOfDay = startOfDay + 1 days - 1;
if (currentTimestamp < startOfDay - 1 days + 1 || currentTimestamp > endOfDay + 1 days) {
revert CurrentDateNotInValidRange();
}
_validateDateInRange(currentTimestamp);
}
function _performNumericCurrentDateCheck(
GenericProofStruct memory vcAndDiscloseProof,
CircuitConstantsV2.DiscloseIndices memory indices
) internal view {
// date is going to be 2025, 12, 13
uint256[3] memory dateNum;
dateNum[0] = vcAndDiscloseProof.pubSignals[indices.currentDateIndex];
dateNum[1] = vcAndDiscloseProof.pubSignals[indices.currentDateIndex + 1];
dateNum[2] = vcAndDiscloseProof.pubSignals[indices.currentDateIndex + 2];
/**
* @notice Validates that a timestamp is within the acceptable range
* @param currentTimestamp The timestamp to validate
*/
function _validateDateInRange(uint256 currentTimestamp) internal view {
// Calculate the timestamp for the start of current date by subtracting the remainder of block.timestamp modulo 1 day
uint256 startOfDay = block.timestamp - (block.timestamp % 1 days);
uint256 currentTimestamp = Formatter.proofDateToUnixTimestampNumeric(dateNum);
uint256 startOfDay = _getStartOfDayTimestamp();
uint256 endOfDay = startOfDay + 1 days - 1;
if (currentTimestamp < startOfDay - 1 days + 1 || currentTimestamp > endOfDay + 1 days) {
// Check if timestamp is within range
if (currentTimestamp < startOfDay - 1 days + 1 || currentTimestamp > startOfDay + 1 days - 1) {
revert CurrentDateNotInValidRange();
}
}

View File

@@ -49,3 +49,21 @@ interface IVcAndDiscloseAadhaarCircuitVerifier {
uint256[19] calldata pubSignals
) external view returns (bool);
}
interface IVcAndDiscloseSelfricaCircuitVerifier {
/**
* @notice Verifies a given VC and Disclose zero-knowledge proof.
* @dev This function checks the validity of the provided proof parameters.
* @param a The 'a' component of the proof.
* @param b The 'b' component of the proof.
* @param c The 'c' component of the proof.
* @param pubSignals The public signals associated with the proof.
* @return A boolean value indicating whether the proof is valid (true) or not (false).
*/
function verifyProof(
uint256[2] calldata a,
uint256[2][2] calldata b,
uint256[2] calldata c,
uint256[30] calldata pubSignals
) external view returns (bool);
}

View File

@@ -70,6 +70,8 @@ abstract contract IdentityRegistryAadhaarStorageV1 is ImplRoot {
* @title IdentityRegistryAadhaarImplV1
* @notice Provides functions to register and manage identity commitments using a Merkle tree structure.
* @dev Inherits from IdentityRegistryAadhaarStorageV1 and implements IIdentityRegistryAadhaarV1.
*
* @custom:version 1.2.0
*/
contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIdentityRegistryAadhaarV1 {
using InternalLeanIMT for LeanIMTData;
@@ -151,6 +153,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
// ====================================================
/// @notice Constructor for the IdentityRegistryAadhaarImplV1 contract.
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
@@ -168,6 +171,19 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
emit RegistryInitialized(_hub);
}
/**
* @notice Initializes AccessControl governance.
* @dev Used when upgrading from Ownable to AccessControl governance.
* This function sets up AccessControl roles on an already-initialized contract.
* It does NOT modify existing state (hub, roots, etc.).
*
* SECURITY: This function can only be called once - enforced by reinitializer(2).
* The previous version used reinitializer(1), so this upgrade uses version 2.
*/
function initializeGovernance() external reinitializer(2) {
__ImplRoot_init();
}
// ====================================================
// External Functions - View & Checks
// ====================================================
@@ -280,7 +296,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @notice Updates the hub address.
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param newHubAddress The new address of the hub.
function updateHub(address newHubAddress) external onlyProxy onlyOwner {
function updateHub(address newHubAddress) external onlyProxy onlyRole(SECURITY_ROLE) {
if (newHubAddress == address(0)) revert HUB_ADDRESS_ZERO();
_hub = newHubAddress;
emit HubUpdated(newHubAddress);
@@ -289,7 +305,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @notice Updates the name and date of birth OFAC root.
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param newNameAndDobOfacRoot The new name and date of birth OFAC root value.
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyOwner {
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_nameAndDobOfacRoot = newNameAndDobOfacRoot;
emit NameAndDobOfacRootUpdated(newNameAndDobOfacRoot);
}
@@ -297,7 +313,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @notice Updates the name and year of birth OFAC root.
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param newNameAndYobOfacRoot The new name and year of birth OFAC root value.
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyOwner {
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_nameAndYobOfacRoot = newNameAndYobOfacRoot;
emit NameAndYobOfacRootUpdated(newNameAndYobOfacRoot);
}
@@ -305,7 +321,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @notice Registers a new UIDAI pubkey commitment.
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param commitment The UIDAI pubkey commitment to register.
function registerUidaiPubkeyCommitment(uint256 commitment) external onlyProxy onlyOwner {
function registerUidaiPubkeyCommitment(uint256 commitment) external onlyProxy onlyRole(SECURITY_ROLE) {
_uidaiPubkeyCommitments[commitment] = true;
emit UidaiPubkeyCommitmentRegistered(commitment, block.timestamp);
}
@@ -313,7 +329,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @notice Removes a UIDAI pubkey commitment.
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param commitment The UIDAI pubkey commitment to remove.
function removeUidaiPubkeyCommitment(uint256 commitment) external onlyProxy onlyOwner {
function removeUidaiPubkeyCommitment(uint256 commitment) external onlyProxy onlyRole(SECURITY_ROLE) {
delete _uidaiPubkeyCommitments[commitment];
emit UidaiPubkeyCommitmentRemoved(commitment, block.timestamp);
}
@@ -321,7 +337,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @notice Updates a UIDAI pubkey commitment.
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param commitment The UIDAI pubkey commitment to update.
function updateUidaiPubkeyCommitment(uint256 commitment) external onlyProxy onlyOwner {
function updateUidaiPubkeyCommitment(uint256 commitment) external onlyProxy onlyRole(SECURITY_ROLE) {
_uidaiPubkeyCommitments[commitment] = true;
emit UidaiPubkeyCommitmentUpdated(commitment, block.timestamp);
}
@@ -335,7 +351,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
bytes32 attestationId,
uint256 nullifier,
uint256 commitment
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
_nullifiers[nullifier] = true;
uint256 imt_root = _identityCommitmentIMT._insert(commitment);
_rootTimestamps[imt_root] = block.timestamp;
@@ -352,7 +368,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
uint256 oldLeaf,
uint256 newLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _identityCommitmentIMT._update(oldLeaf, newLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentUpdated(oldLeaf, newLeaf, imt_root, block.timestamp);
@@ -362,7 +378,10 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @dev Caller must be the owner. Provides sibling nodes for proof of position.
/// @param oldLeaf The identity commitment to remove.
/// @param siblingNodes An array of sibling nodes for Merkle proof generation.
function devRemoveCommitment(uint256 oldLeaf, uint256[] calldata siblingNodes) external onlyProxy onlyOwner {
function devRemoveCommitment(
uint256 oldLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _identityCommitmentIMT._remove(oldLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentRemoved(oldLeaf, imt_root, block.timestamp);

View File

@@ -77,6 +77,8 @@ abstract contract IdentityRegistryIdCardStorageV1 is ImplRoot {
* @title IdentityRegistryImplV1
* @notice Provides functions to register and manage identity commitments using a Merkle tree structure.
* @dev Inherits from IdentityRegistryStorageV1 and implements IIdentityRegistryV1.
*
* @custom:version 1.2.0
*/
contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdentityRegistryIdCardV1 {
using InternalLeanIMT for LeanIMTData;
@@ -162,6 +164,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
/**
* @notice Constructor that disables initializers.
* @dev Prevents direct initialization of the implementation contract.
* @custom:oz-upgrades-unsafe-allow constructor
*/
constructor() {
_disableInitializers();
@@ -181,6 +184,19 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
emit RegistryInitialized(_hub);
}
/**
* @notice Initializes governance for upgraded contracts.
* @dev Used when upgrading from Ownable to AccessControl governance.
* This function sets up AccessControl roles on an already-initialized contract.
* It does NOT modify existing state (hub, roots, etc.).
*
* SECURITY: This function can only be called once - enforced by reinitializer(2).
* The previous version used reinitializer(1), so this upgrade uses version 2.
*/
function initializeGovernance() external reinitializer(2) {
__ImplRoot_init();
}
// ====================================================
// External Functions - View & Checks
// ====================================================
@@ -380,7 +396,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newHubAddress The new address of the hub.
*/
function updateHub(address newHubAddress) external onlyProxy onlyOwner {
function updateHub(address newHubAddress) external onlyProxy onlyRole(SECURITY_ROLE) {
_hub = newHubAddress;
emit HubUpdated(newHubAddress);
}
@@ -390,7 +406,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newNameAndDobOfacRoot The new name and date of birth OFAC root value.
*/
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyOwner {
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_nameAndDobOfacRoot = newNameAndDobOfacRoot;
emit NameAndDobOfacRootUpdated(newNameAndDobOfacRoot);
}
@@ -400,7 +416,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newNameAndYobOfacRoot The new name and year of birth OFAC root value.
*/
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyOwner {
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_nameAndYobOfacRoot = newNameAndYobOfacRoot;
emit NameAndYobOfacRootUpdated(newNameAndYobOfacRoot);
}
@@ -410,7 +426,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newCscaRoot The new CSCA root value.
*/
function updateCscaRoot(uint256 newCscaRoot) external onlyProxy onlyOwner {
function updateCscaRoot(uint256 newCscaRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_cscaRoot = newCscaRoot;
emit CscaRootUpdated(newCscaRoot);
}
@@ -426,7 +442,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
bytes32 attestationId,
uint256 nullifier,
uint256 commitment
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
_nullifiers[attestationId][nullifier] = true;
uint256 imt_root = _addCommitment(_identityCommitmentIMT, commitment);
_rootTimestamps[imt_root] = block.timestamp;
@@ -445,7 +461,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
uint256 oldLeaf,
uint256 newLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _updateCommitment(_identityCommitmentIMT, oldLeaf, newLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentUpdated(oldLeaf, newLeaf, imt_root, block.timestamp);
@@ -457,7 +473,10 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @param oldLeaf The identity commitment to remove.
* @param siblingNodes An array of sibling nodes for Merkle proof generation.
*/
function devRemoveCommitment(uint256 oldLeaf, uint256[] calldata siblingNodes) external onlyProxy onlyOwner {
function devRemoveCommitment(
uint256 oldLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _removeCommitment(_identityCommitmentIMT, oldLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentRemoved(oldLeaf, imt_root, block.timestamp);
@@ -468,7 +487,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @dev Callable only by the owner for testing or administration.
* @param dscCommitment The DSC key commitment to add.
*/
function devAddDscKeyCommitment(uint256 dscCommitment) external onlyProxy onlyOwner {
function devAddDscKeyCommitment(uint256 dscCommitment) external onlyProxy onlyRole(SECURITY_ROLE) {
_isRegisteredDscKeyCommitment[dscCommitment] = true;
uint256 imt_root = _addCommitment(_dscKeyCommitmentIMT, dscCommitment);
uint256 index = _dscKeyCommitmentIMT._indexOf(dscCommitment);
@@ -486,7 +505,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
uint256 oldLeaf,
uint256 newLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _updateCommitment(_dscKeyCommitmentIMT, oldLeaf, newLeaf, siblingNodes);
emit DevDscKeyCommitmentUpdated(oldLeaf, newLeaf, imt_root);
}
@@ -497,7 +516,10 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @param oldLeaf The DSC key commitment to remove.
* @param siblingNodes An array of sibling nodes for Merkle proof generation.
*/
function devRemoveDscKeyCommitment(uint256 oldLeaf, uint256[] calldata siblingNodes) external onlyProxy onlyOwner {
function devRemoveDscKeyCommitment(
uint256 oldLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _removeCommitment(_dscKeyCommitmentIMT, oldLeaf, siblingNodes);
emit DevDscKeyCommitmentRemoved(oldLeaf, imt_root);
}
@@ -513,7 +535,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
bytes32 attestationId,
uint256 nullifier,
bool state
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
_nullifiers[attestationId][nullifier] = state;
emit DevNullifierStateChanged(attestationId, nullifier, state);
}
@@ -524,7 +546,10 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @param dscCommitment The DSC key commitment.
* @param state The new state of the DSC key commitment (true for registered, false for not registered).
*/
function devChangeDscKeyCommitmentState(uint256 dscCommitment, bool state) external onlyProxy onlyOwner {
function devChangeDscKeyCommitmentState(
uint256 dscCommitment,
bool state
) external onlyProxy onlyRole(SECURITY_ROLE) {
_isRegisteredDscKeyCommitment[dscCommitment] = state;
emit DevDscKeyCommitmentStateChanged(dscCommitment, state);
}

View File

@@ -82,6 +82,8 @@ abstract contract IdentityRegistryStorageV1 is ImplRoot {
* @title IdentityRegistryImplV1
* @notice Provides functions to register and manage identity commitments using a Merkle tree structure.
* @dev Inherits from IdentityRegistryStorageV1 and implements IIdentityRegistryV1.
*
* @custom:version 1.2.0
*/
contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV1 {
using InternalLeanIMT for LeanIMTData;
@@ -169,6 +171,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
/**
* @notice Constructor that disables initializers.
* @dev Prevents direct initialization of the implementation contract.
* @custom:oz-upgrades-unsafe-allow constructor
*/
constructor() {
_disableInitializers();
@@ -188,6 +191,19 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
emit RegistryInitialized(hubAddress);
}
/**
* @notice Initializes governance for upgraded contracts.
* @dev Used when upgrading from Ownable to AccessControl governance.
* This function sets up AccessControl roles on an already-initialized contract.
* It does NOT modify existing state (hub, roots, etc.).
*
* SECURITY: This function can only be called once - enforced by reinitializer(2).
* The previous version used reinitializer(1), so this upgrade uses version 2.
*/
function initializeGovernance() external reinitializer(2) {
__ImplRoot_init();
}
// ====================================================
// External Functions - View & Checks
// ====================================================
@@ -403,7 +419,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newHubAddress The new address of the hub.
*/
function updateHub(address newHubAddress) external onlyProxy onlyOwner {
function updateHub(address newHubAddress) external onlyProxy onlyRole(SECURITY_ROLE) {
_hub = newHubAddress;
emit HubUpdated(newHubAddress);
}
@@ -413,7 +429,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newPassportNoOfacRoot The new passport number OFAC root value.
*/
function updatePassportNoOfacRoot(uint256 newPassportNoOfacRoot) external onlyProxy onlyOwner {
function updatePassportNoOfacRoot(uint256 newPassportNoOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_passportNoOfacRoot = newPassportNoOfacRoot;
emit PassportNoOfacRootUpdated(newPassportNoOfacRoot);
}
@@ -423,7 +439,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newNameAndDobOfacRoot The new name and date of birth OFAC root value.
*/
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyOwner {
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_nameAndDobOfacRoot = newNameAndDobOfacRoot;
emit NameAndDobOfacRootUpdated(newNameAndDobOfacRoot);
}
@@ -433,7 +449,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newNameAndYobOfacRoot The new name and year of birth OFAC root value.
*/
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyOwner {
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_nameAndYobOfacRoot = newNameAndYobOfacRoot;
emit NameAndYobOfacRootUpdated(newNameAndYobOfacRoot);
}
@@ -443,7 +459,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @dev Callable only via a proxy and restricted to the contract owner.
* @param newCscaRoot The new CSCA root value.
*/
function updateCscaRoot(uint256 newCscaRoot) external onlyProxy onlyOwner {
function updateCscaRoot(uint256 newCscaRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_cscaRoot = newCscaRoot;
emit CscaRootUpdated(newCscaRoot);
}
@@ -459,7 +475,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
bytes32 attestationId,
uint256 nullifier,
uint256 commitment
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
_nullifiers[attestationId][nullifier] = true;
uint256 imt_root = _addCommitment(_identityCommitmentIMT, commitment);
_rootTimestamps[imt_root] = block.timestamp;
@@ -478,7 +494,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
uint256 oldLeaf,
uint256 newLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _updateCommitment(_identityCommitmentIMT, oldLeaf, newLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentUpdated(oldLeaf, newLeaf, imt_root, block.timestamp);
@@ -490,7 +506,10 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @param oldLeaf The identity commitment to remove.
* @param siblingNodes An array of sibling nodes for Merkle proof generation.
*/
function devRemoveCommitment(uint256 oldLeaf, uint256[] calldata siblingNodes) external onlyProxy onlyOwner {
function devRemoveCommitment(
uint256 oldLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _removeCommitment(_identityCommitmentIMT, oldLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentRemoved(oldLeaf, imt_root, block.timestamp);
@@ -501,7 +520,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @dev Callable only by the owner for testing or administration.
* @param dscCommitment The DSC key commitment to add.
*/
function devAddDscKeyCommitment(uint256 dscCommitment) external onlyProxy onlyOwner {
function devAddDscKeyCommitment(uint256 dscCommitment) external onlyProxy onlyRole(SECURITY_ROLE) {
_isRegisteredDscKeyCommitment[dscCommitment] = true;
uint256 imt_root = _addCommitment(_dscKeyCommitmentIMT, dscCommitment);
uint256 index = _dscKeyCommitmentIMT._indexOf(dscCommitment);
@@ -519,7 +538,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
uint256 oldLeaf,
uint256 newLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _updateCommitment(_dscKeyCommitmentIMT, oldLeaf, newLeaf, siblingNodes);
emit DevDscKeyCommitmentUpdated(oldLeaf, newLeaf, imt_root);
}
@@ -530,7 +549,10 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @param oldLeaf The DSC key commitment to remove.
* @param siblingNodes An array of sibling nodes for Merkle proof generation.
*/
function devRemoveDscKeyCommitment(uint256 oldLeaf, uint256[] calldata siblingNodes) external onlyProxy onlyOwner {
function devRemoveDscKeyCommitment(
uint256 oldLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _removeCommitment(_dscKeyCommitmentIMT, oldLeaf, siblingNodes);
emit DevDscKeyCommitmentRemoved(oldLeaf, imt_root);
}
@@ -546,7 +568,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
bytes32 attestationId,
uint256 nullifier,
bool state
) external onlyProxy onlyOwner {
) external onlyProxy onlyRole(SECURITY_ROLE) {
_nullifiers[attestationId][nullifier] = state;
emit DevNullifierStateChanged(attestationId, nullifier, state);
}
@@ -557,7 +579,10 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @param dscCommitment The DSC key commitment.
* @param state The new state of the DSC key commitment (true for registered, false for not registered).
*/
function devChangeDscKeyCommitmentState(uint256 dscCommitment, bool state) external onlyProxy onlyOwner {
function devChangeDscKeyCommitmentState(
uint256 dscCommitment,
bool state
) external onlyProxy onlyRole(SECURITY_ROLE) {
_isRegisteredDscKeyCommitment[dscCommitment] = state;
emit DevDscKeyCommitmentStateChanged(dscCommitment, state);
}

View File

@@ -3,22 +3,36 @@ pragma solidity 0.8.28;
import {IIdentityVerificationHubV1} from "../interfaces/IIdentityVerificationHubV1.sol";
import {IIdentityRegistryV1} from "../interfaces/IIdentityRegistryV1.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {CircuitConstants} from "../constants/CircuitConstants.sol";
/// @title VerifyAll
/// @notice A contract for verifying identity proofs and revealing selected data
/// @dev This contract interacts with IdentityVerificationHub and IdentityRegistry
contract VerifyAll is Ownable {
contract VerifyAll is AccessControl {
/// @notice Critical operations and role management requiring 3/5 multisig consensus
bytes32 public constant SECURITY_ROLE = keccak256("SECURITY_ROLE");
/// @notice Standard operations requiring 2/5 multisig consensus
bytes32 public constant OPERATIONS_ROLE = keccak256("OPERATIONS_ROLE");
IIdentityVerificationHubV1 public hub;
IIdentityRegistryV1 public registry;
/// @notice Initializes the contract with hub and registry addresses
/// @param hubAddress The address of the IdentityVerificationHub contract
/// @param registryAddress The address of the IdentityRegistry contract
constructor(address hubAddress, address registryAddress) Ownable(msg.sender) {
constructor(address hubAddress, address registryAddress) {
hub = IIdentityVerificationHubV1(hubAddress);
registry = IIdentityRegistryV1(registryAddress);
// Grant all roles to deployer initially
_grantRole(SECURITY_ROLE, msg.sender);
_grantRole(OPERATIONS_ROLE, msg.sender);
// Set role admins - SECURITY_ROLE manages all roles
_setRoleAdmin(SECURITY_ROLE, SECURITY_ROLE);
_setRoleAdmin(OPERATIONS_ROLE, SECURITY_ROLE);
}
/// @notice Verifies identity proof and reveals selected data
@@ -107,15 +121,15 @@ contract VerifyAll is Ownable {
/// @notice Updates the hub contract address
/// @param hubAddress The new hub contract address
/// @dev Only callable by the contract owner
function setHub(address hubAddress) external onlyOwner {
/// @dev Only callable by accounts with SECURITY_ROLE
function setHub(address hubAddress) external onlyRole(SECURITY_ROLE) {
hub = IIdentityVerificationHubV1(hubAddress);
}
/// @notice Updates the registry contract address
/// @param registryAddress The new registry contract address
/// @dev Only callable by the contract owner
function setRegistry(address registryAddress) external onlyOwner {
/// @dev Only callable by accounts with SECURITY_ROLE
function setRegistry(address registryAddress) external onlyRole(SECURITY_ROLE) {
registry = IIdentityRegistryV1(registryAddress);
}
}

View File

@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {MockOwnableImplRoot} from "./MockOwnableImplRoot.sol";
/**
* @title MockOwnableHub
* @dev Mock contract that simulates the OLD production Hub using Ownable
* This represents what's currently deployed in production before the governance upgrade.
*/
contract MockOwnableHub is MockOwnableImplRoot {
/// @notice Circuit version for compatibility
uint256 private _circuitVersion;
/// @notice Registry address
address private _registry;
/// @notice Event emitted when hub is initialized
event HubInitialized();
/// @notice Event emitted when registry is updated
event RegistryUpdated(address indexed registry);
/**
* @notice Constructor that disables initializers for the implementation contract.
*/
constructor() {
_disableInitializers();
}
/**
* @notice Initializes the Hub contract (simulates production initialization)
*/
function initialize() external initializer {
__MockOwnableImplRoot_init();
_circuitVersion = 1;
emit HubInitialized();
}
/**
* @notice Updates the registry address (simulates production function)
* @param registryAddress The new registry address
*/
function updateRegistry(address registryAddress) external onlyOwner {
_registry = registryAddress;
emit RegistryUpdated(registryAddress);
}
/**
* @notice Gets the circuit version
*/
function getCircuitVersion() external view returns (uint256) {
return _circuitVersion;
}
/**
* @notice Gets the registry address
*/
function getRegistry() external view returns (address) {
return _registry;
}
/**
* @notice Updates the circuit version
* @param version The new circuit version
*/
function updateCircuitVersion(uint256 version) external onlyOwner {
_circuitVersion = version;
}
}

View File

@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
/**
* @title MockOwnableImplRoot
* @dev Mock contract that simulates the OLD production ImplRoot using Ownable2StepUpgradeable
* This represents what's currently deployed in production before the governance upgrade.
*/
abstract contract MockOwnableImplRoot is UUPSUpgradeable, Ownable2StepUpgradeable {
// Reserved storage space to allow for layout changes in the future.
uint256[50] private __gap;
/**
* @dev Initializes the contract by setting the deployer as the initial owner and initializing
* the UUPS proxy functionality.
*/
function __MockOwnableImplRoot_init() internal virtual onlyInitializing {
__Ownable_init(msg.sender);
}
/**
* @dev Authorizes an upgrade to a new implementation.
* Requirements:
* - Must be called through a proxy.
* - Caller must be the owner.
*/
function _authorizeUpgrade(address newImplementation) internal virtual override onlyProxy onlyOwner {}
}

View File

@@ -0,0 +1,102 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {MockOwnableImplRoot} from "./MockOwnableImplRoot.sol";
/**
* @title MockOwnableRegistry
* @dev Mock contract that simulates the OLD production Registry using Ownable
* This represents what's currently deployed in production before the governance upgrade.
*/
contract MockOwnableRegistry is MockOwnableImplRoot {
/// @notice Hub address
address private _hub;
/// @notice CSCA Root
bytes32 private _cscaRoot;
/// @notice Some registry data
mapping(bytes32 => bool) private _commitments;
/// @notice Event emitted when registry is initialized
event RegistryInitialized(address indexed hub);
/// @notice Event emitted when hub is updated
event HubUpdated(address indexed hub);
/// @notice Event emitted when CSCA root is updated
event CscaRootUpdated(bytes32 indexed cscaRoot);
/**
* @notice Constructor that disables initializers for the implementation contract.
*/
constructor() {
_disableInitializers();
}
/**
* @notice Initializes the Registry contract (simulates production initialization)
* @param hubAddress The hub address
*/
function initialize(address hubAddress) external initializer {
__MockOwnableImplRoot_init();
_hub = hubAddress;
emit RegistryInitialized(hubAddress);
}
/**
* @notice Sets the hub address (simulates production function)
* @param hubAddress The new hub address
*/
function setHub(address hubAddress) external onlyOwner {
_hub = hubAddress;
emit HubUpdated(hubAddress);
}
/**
* @notice Updates the hub address (simulates production function)
* @param hubAddress The new hub address
*/
function updateHub(address hubAddress) external onlyOwner {
_hub = hubAddress;
emit HubUpdated(hubAddress);
}
/**
* @notice Updates the CSCA root (simulates production function)
* @param cscaRoot The new CSCA root
*/
function updateCscaRoot(bytes32 cscaRoot) external onlyOwner {
_cscaRoot = cscaRoot;
emit CscaRootUpdated(cscaRoot);
}
/**
* @notice Adds a commitment (simulates production function)
* @param commitment The commitment to add
*/
function addCommitment(bytes32 commitment) external onlyOwner {
_commitments[commitment] = true;
}
/**
* @notice Gets the hub address
*/
function getHub() external view returns (address) {
return _hub;
}
/**
* @notice Gets the CSCA root
*/
function getCscaRoot() external view returns (bytes32) {
return _cscaRoot;
}
/**
* @notice Checks if a commitment exists
*/
function hasCommitment(bytes32 commitment) external view returns (bool) {
return _commitments[commitment];
}
}

View File

@@ -0,0 +1,82 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {ImplRoot} from "../upgradeable/ImplRoot.sol";
/**
* @title MockUpgradedHub
* @dev Mock contract that simulates the NEW Hub with AccessControl governance
* This represents what the Hub will look like after the governance upgrade.
*/
contract MockUpgradedHub is ImplRoot {
/// @notice Circuit version for compatibility
uint256 private _circuitVersion;
/// @notice Registry address
address private _registry;
/// @notice Event emitted when hub is initialized with governance
event HubGovernanceInitialized();
/// @notice Event emitted when registry is updated
event RegistryUpdated(address indexed registry);
/**
* @notice Constructor that disables initializers for the implementation contract.
*/
constructor() {
_disableInitializers();
}
/**
* @notice Initializes governance for the upgraded Hub
* This should be called after the upgrade to set up AccessControl
* NOTE: This ONLY initializes governance roles, does NOT modify existing state
*/
function initialize() external reinitializer(2) {
__ImplRoot_init();
// DO NOT modify _registry or _circuitVersion - they should be preserved from before upgrade!
emit HubGovernanceInitialized();
}
/**
* @notice Updates the registry address (now requires SECURITY_ROLE)
* @param registryAddress The new registry address
*/
function updateRegistry(address registryAddress) external onlyRole(SECURITY_ROLE) {
_registry = registryAddress;
emit RegistryUpdated(registryAddress);
}
/**
* @notice Updates circuit version (requires SECURITY_ROLE)
* @param version The new circuit version
*/
function updateCircuitVersion(uint256 version) external onlyRole(SECURITY_ROLE) {
_circuitVersion = version;
}
/**
* @notice Gets the circuit version
*/
function getCircuitVersion() external view returns (uint256) {
return _circuitVersion;
}
/**
* @notice Gets the registry address
*/
function getRegistry() external view returns (address) {
return _registry;
}
/**
* @notice Checks if the upgrade preserved critical storage data
* This is a verification function to ensure storage migration worked
*/
function verifyStorageMigration() external view returns (bool) {
// The important thing is that the contract is functional and governance works
// Registry and circuit version should be preserved, deprecated owner may be zero
return hasRole(SECURITY_ROLE, msg.sender) || hasRole(OPERATIONS_ROLE, msg.sender);
}
}

View File

@@ -0,0 +1,103 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {ImplRoot} from "../upgradeable/ImplRoot.sol";
/**
* @title MockUpgradedRegistry
* @dev Mock contract that simulates the NEW Registry with AccessControl governance
* This represents what the Registry will look like after the governance upgrade.
*/
contract MockUpgradedRegistry is ImplRoot {
/// @notice Hub address
address private _hub;
/// @notice CSCA Root
bytes32 private _cscaRoot;
/// @notice Some registry data
mapping(bytes32 => bool) private _commitments;
/// @notice Event emitted when registry governance is initialized
event RegistryGovernanceInitialized();
/// @notice Event emitted when hub is updated
event HubUpdated(address indexed hub);
/// @notice Event emitted when CSCA root is updated
event CscaRootUpdated(bytes32 indexed cscaRoot);
/**
* @notice Constructor that disables initializers for the implementation contract.
*/
constructor() {
_disableInitializers();
}
/**
* @notice Initializes governance for the upgraded Registry
* This should be called after the upgrade to set up AccessControl
* NOTE: This ONLY initializes governance roles, does NOT modify existing state
*/
function initialize() external reinitializer(2) {
__ImplRoot_init();
// DO NOT modify _hub or _cscaRoot - they should be preserved from before upgrade!
emit RegistryGovernanceInitialized();
}
/**
* @notice Sets the hub address (now requires SECURITY_ROLE)
* @param hubAddress The new hub address
*/
function setHub(address hubAddress) external onlyRole(SECURITY_ROLE) {
_hub = hubAddress;
emit HubUpdated(hubAddress);
}
/**
* @notice Updates the hub address (now requires SECURITY_ROLE)
* @param hubAddress The new hub address
*/
function updateHub(address hubAddress) external onlyRole(SECURITY_ROLE) {
_hub = hubAddress;
emit HubUpdated(hubAddress);
}
/**
* @notice Updates the CSCA root (now requires SECURITY_ROLE)
* @param cscaRoot The new CSCA root
*/
function updateCscaRoot(bytes32 cscaRoot) external onlyRole(OPERATIONS_ROLE) {
_cscaRoot = cscaRoot;
emit CscaRootUpdated(cscaRoot);
}
/**
* @notice Adds a commitment (now requires SECURITY_ROLE)
* @param commitment The commitment to add
*/
function addCommitment(bytes32 commitment) external onlyRole(SECURITY_ROLE) {
_commitments[commitment] = true;
}
/**
* @notice Gets the hub address
*/
function getHub() external view returns (address) {
return _hub;
}
/**
* @notice Gets the CSCA root
*/
function getCscaRoot() external view returns (bytes32) {
return _cscaRoot;
}
/**
* @notice Checks if a commitment exists
*/
function hasCommitment(bytes32 commitment) external view returns (bool) {
return _commitments[commitment];
}
}

View File

@@ -4,15 +4,23 @@ pragma solidity 0.8.28;
import {ImplRoot} from "../../contracts/upgradeable/ImplRoot.sol";
contract MockImplRoot is ImplRoot {
function exposed__ImplRoot_init() external {
function exposed__ImplRoot_init() external initializer {
__ImplRoot_init();
}
function exposed__Ownable_init(address initialOwner) external initializer {
__Ownable_init(initialOwner);
}
function exposed_authorizeUpgrade(address newImplementation) external {
_authorizeUpgrade(newImplementation);
}
function exposed_grantRole(bytes32 role, address account) external {
_grantRole(role, account);
}
function exposed_revokeRole(bytes32 role, address account) external {
_revokeRole(role, account);
}
function exposed_setRoleAdmin(bytes32 role, bytes32 adminRole) external {
_setRoleAdmin(role, adminRole);
}
}

View File

@@ -47,7 +47,7 @@ contract testUpgradedIdentityVerificationHubImplV1 is
* @param isTestInput Boolean value which shows it is test or not
*/
function initialize(bool isTestInput) external reinitializer(3) {
__ImplRoot_init();
__Ownable_init(msg.sender);
_isTest = isTestInput;
emit TestHubInitialized();
}

View File

@@ -2,15 +2,25 @@
pragma solidity 0.8.28;
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
/**
* @title ImplRoot
* @dev Abstract contract providing upgradeable functionality via UUPSUpgradeable,
* along with a two-step ownable mechanism using Ownable2StepUpgradeable.
* along with role-based access control using AccessControlUpgradeable.
* Serves as a base for upgradeable implementations.
*
* Governance Roles:
* - SECURITY_ROLE: Security-sensitive operations and role management (3/5 multisig consensus)
* - OPERATIONS_ROLE: Routine operational tasks (2/5 multisig consensus)
*/
abstract contract ImplRoot is UUPSUpgradeable, Ownable2StepUpgradeable {
abstract contract ImplRoot is UUPSUpgradeable, AccessControlUpgradeable {
/// @notice Security-sensitive operations requiring 3/5 multisig consensus
bytes32 public constant SECURITY_ROLE = keccak256("SECURITY_ROLE");
/// @notice Routine operations requiring 2/5 multisig consensus
bytes32 public constant OPERATIONS_ROLE = keccak256("OPERATIONS_ROLE");
// Reserved storage space to allow for layout changes in the future.
uint256[50] private __gap;
@@ -21,17 +31,23 @@ abstract contract ImplRoot is UUPSUpgradeable, Ownable2StepUpgradeable {
* This function should be called in the initializer of the derived contract.
*/
function __ImplRoot_init() internal virtual onlyInitializing {
__Ownable_init(msg.sender);
__UUPSUpgradeable_init();
__AccessControl_init();
_grantRole(SECURITY_ROLE, msg.sender);
_grantRole(OPERATIONS_ROLE, msg.sender);
// Set role admins - SECURITY_ROLE manages all roles
_setRoleAdmin(SECURITY_ROLE, SECURITY_ROLE);
_setRoleAdmin(OPERATIONS_ROLE, SECURITY_ROLE);
}
/**
* @dev Authorizes an upgrade to a new implementation.
* Requirements:
* - Must be called through a proxy.
* - Caller must be the contract owner.
* - Caller must have SECURITY_ROLE.
*
* @param newImplementation The address of the new implementation contract.
*/
function _authorizeUpgrade(address newImplementation) internal virtual override onlyProxy onlyOwner {}
function _authorizeUpgrade(address newImplementation) internal virtual override onlyProxy onlyRole(SECURITY_ROLE) {}
}

View File

@@ -1,18 +1,32 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/access/Ownable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
/**
* @title PCR0Manager
* @notice This contract manages a mapping of PCR0 values (provided as a 48-byte value)
* to booleans. The PCR0 value (the 48-byte SHA384 output) is hashed
* using keccak256 and then stored in the mapping.
* Only the owner can add or remove entries.
* Only accounts with SECURITY_ROLE can add or remove entries.
* @custom:version 1.2.0
*/
contract PCR0Manager is Ownable {
// Pass msg.sender directly to Ownable constructor
constructor() Ownable(msg.sender) {}
contract PCR0Manager is AccessControl {
/// @notice Critical operations and role management requiring 3/5 multisig consensus
bytes32 public constant SECURITY_ROLE = keccak256("SECURITY_ROLE");
/// @notice Standard operations requiring 2/5 multisig consensus
bytes32 public constant OPERATIONS_ROLE = keccak256("OPERATIONS_ROLE");
constructor() {
// Grant all roles to deployer initially
_grantRole(SECURITY_ROLE, msg.sender);
_grantRole(OPERATIONS_ROLE, msg.sender);
// Set role admins - SECURITY_ROLE is admin of both roles
_setRoleAdmin(SECURITY_ROLE, SECURITY_ROLE);
_setRoleAdmin(OPERATIONS_ROLE, SECURITY_ROLE);
}
// Mapping from keccak256(pcr0) to its boolean state.
mapping(bytes32 => bool) public pcr0Mapping;
@@ -27,12 +41,14 @@ contract PCR0Manager is Ownable {
/**
* @notice Adds a new PCR0 entry by setting its value to true.
* @param pcr0 The PCR0 value (must be exactly 48 bytes).
* @dev Reverts if the PCR0 value is not 48 bytes or if it is already set.
* @param pcr0 The PCR0 value (must be exactly 32 bytes).
* @dev Reverts if the PCR0 value is not 32 bytes or if it is already set.
* @dev Pads the PCR0 value to 48 bytes by prefixing 16 zero bytes to maintain mobile app compatibility.
*/
function addPCR0(bytes calldata pcr0) external onlyOwner {
require(pcr0.length == 48, "PCR0 must be 48 bytes");
bytes32 key = keccak256(pcr0);
function addPCR0(bytes calldata pcr0) external onlyRole(SECURITY_ROLE) {
require(pcr0.length == 32, "PCR0 must be 32 bytes");
bytes memory paddedPcr0 = abi.encodePacked(new bytes(16), pcr0);
bytes32 key = keccak256(paddedPcr0);
require(!pcr0Mapping[key], "PCR0 already set");
pcr0Mapping[key] = true;
emit PCR0Added(key);
@@ -40,12 +56,14 @@ contract PCR0Manager is Ownable {
/**
* @notice Removes an existing PCR0 entry by setting its value to false.
* @param pcr0 The PCR0 value (must be exactly 48 bytes).
* @dev Reverts if the PCR0 value is not 48 bytes or if it is not currently set.
* @param pcr0 The PCR0 value (must be exactly 32 bytes).
* @dev Reverts if the PCR0 value is not 32 bytes or if it is not currently set.
* @dev Pads the PCR0 value to 48 bytes by prefixing 16 zero bytes to maintain mobile app compatibility.
*/
function removePCR0(bytes calldata pcr0) external onlyOwner {
require(pcr0.length == 48, "PCR0 must be 48 bytes");
bytes32 key = keccak256(pcr0);
function removePCR0(bytes calldata pcr0) external onlyRole(SECURITY_ROLE) {
require(pcr0.length == 32, "PCR0 must be 32 bytes");
bytes memory paddedPcr0 = abi.encodePacked(new bytes(16), pcr0);
bytes32 key = keccak256(paddedPcr0);
require(pcr0Mapping[key], "PCR0 not set");
pcr0Mapping[key] = false;
emit PCR0Removed(key);
@@ -54,6 +72,8 @@ contract PCR0Manager is Ownable {
/**
* @notice Checks whether a given PCR0 value is set to true in the mapping.
* @param pcr0 The PCR0 value (must be exactly 48 bytes).
* @dev Does not pad the PCR0 value as this is handled by the mobile app.
* @dev If you are manually calling this function, you need to pad the PCR0 value to 48 bytes, prefixing 16 zero bytes.
* @return exists True if the PCR0 entry is set, false otherwise.
*/
function isPCR0Set(bytes calldata pcr0) external view returns (bool exists) {

View File

@@ -37,25 +37,25 @@ contract Verifier_register_aadhaar {
uint256 constant gammax2 = 10857046999023057135944570762232829481370756359578518086990519993285655852781;
uint256 constant gammay1 = 4082367875863433681332203403145435568316851327593401208105741076214120093531;
uint256 constant gammay2 = 8495653923123431417604973247489272438418190587263600148770280649306958101930;
uint256 constant deltax1 = 1184175006002790631176821634090938467107330227007158853824891629496015889924;
uint256 constant deltax2 = 12086636205582787465813058141825079064824697543086779109775595053805081617827;
uint256 constant deltay1 = 4456837667197728326322115376478122146150647259307011732553476664405503785753;
uint256 constant deltay2 = 9088696651190771223855139438876954166862164661620992858425695135876196457926;
uint256 constant deltax1 = 3953219198104570901098823830840773856017689139278458081183220490752145815050;
uint256 constant deltax2 = 428186582661072144108009098107578252463491462238432931497262180014713596115;
uint256 constant deltay1 = 18162968189172780580333095558539690618880186036957545736311404283407493778880;
uint256 constant deltay2 = 7343682947937413219111184190299798376421430633278379635221606345245958931239;
uint256 constant IC0x = 6547380589242664979389953612506618657067204598675122139604885565320676833158;
uint256 constant IC0y = 19055399919951028177234969337049077818155869440497248883170998389487338107126;
uint256 constant IC0x = 18984838814932147425072354846429508676387686524229308734161716095463360490134;
uint256 constant IC0y = 11220857659665071811279473081460089783437970319349511357818317231596300603739;
uint256 constant IC1x = 20557545828033851521979343305884318041481443328161582179150888164584749744669;
uint256 constant IC1y = 21560118189953885636148717201222479281100786469743463492679572665614931385205;
uint256 constant IC2x = 17559551632997878871440402139938294429514970824368869332125462241643052815376;
uint256 constant IC2y = 18428425902276807983388946110037886804676016275275246286544615654725514849838;
uint256 constant IC2x = 16679737504993527028036863898232919844061144900682159566005073982271081014169;
uint256 constant IC2y = 608284743266912406546568650108359232826801114144551290914053108404596136834;
uint256 constant IC3x = 18768989044514693938417600792629717603460465495191187242290958821278680606604;
uint256 constant IC3y = 6584358559179261704032830455997936799129839324733806160004605275139747821694;
uint256 constant IC3x = 10860675204793746311158823740347274485680296774011483979610282012223174969615;
uint256 constant IC3y = 15029247271645078761075880233744321449871203863194823447037797284218806524473;
uint256 constant IC4x = 16692378542219000347024593964346873649905710163948976095790586330709671710647;
uint256 constant IC4y = 2622311591517607336391164955074698243697841582935873217110527812716210930596;
uint256 constant IC4x = 17894574390662839711557891944994831304061930223868407277717041388786423798517;
uint256 constant IC4y = 10747262533817845366080322542335489573224940900925614580771284497946454436591;
// Memory data
uint16 constant pVk = 0;

View File

@@ -0,0 +1,225 @@
{
"$schema": "./registry.schema.json",
"lastUpdated": "2025-12-10T06:17:50.863Z",
"contracts": {
"IdentityVerificationHub": {
"source": "IdentityVerificationHubImplV2",
"type": "uups-proxy",
"description": "Main identity verification hub for all document types"
},
"IdentityRegistry": {
"source": "IdentityRegistryImplV1",
"type": "uups-proxy",
"description": "Passport identity registry"
},
"IdentityRegistryIdCard": {
"source": "IdentityRegistryIdCardImplV1",
"type": "uups-proxy",
"description": "EU ID Card identity registry"
},
"IdentityRegistryAadhaar": {
"source": "IdentityRegistryAadhaarImplV1",
"type": "uups-proxy",
"description": "Aadhaar identity registry"
},
"PCR0Manager": {
"source": "PCR0Manager",
"type": "non-upgradeable",
"description": "PCR0 value management for TEE verification"
},
"VerifyAll": {
"source": "VerifyAll",
"type": "non-upgradeable",
"description": "SDK verification helper contract"
}
},
"networks": {
"celo": {
"chainId": 42220,
"governance": {
"securityMultisig": "0x738f0bb37FD3b6C4Cdf8eb6FcdFaAA0CA208CB4A",
"operationsMultisig": "0x067b18e09A10Fa03d027c1D60A098CEbbE5637f0",
"securityThreshold": "3/5",
"operationsThreshold": "2/5"
},
"deployments": {
"IdentityVerificationHub": {
"proxy": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
"currentVersion": "2.12.0",
"currentImpl": "0x05FB9D7830889cc389E88198f6A224eA87F01151"
},
"IdentityRegistry": {
"proxy": "0x37F5CB8cB1f6B00aa768D8aA99F1A9289802A968",
"currentVersion": "1.2.0",
"currentImpl": "0x81E7F74560FAF7eE8DE3a36A5a68B6cbc429Cd36"
},
"IdentityRegistryIdCard": {
"proxy": "0xeAD1E6Ec29c1f3D33a0662f253a3a94D189566E1",
"currentVersion": "1.2.0",
"currentImpl": "0x7d5e4b7D4c3029aF134D50642674Af8F875118a4"
},
"IdentityRegistryAadhaar": {
"proxy": "0xd603Fa8C8f4694E8DD1DcE1f27C0C3fc91e32Ac4",
"currentVersion": "1.2.0",
"currentImpl": "0xbD861A9cecf7B0A9631029d55A8CE1155e50697c"
},
"PCR0Manager": {
"address": "0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717",
"currentVersion": "1.2.0"
},
"VerifyAll": {
"address": "",
"currentVersion": "1.0.0"
}
}
},
"localhost": {
"chainId": 31337,
"governance": {
"securityMultisig": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"operationsMultisig": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"securityThreshold": "1/1",
"operationsThreshold": "1/1"
},
"deployments": {}
}
},
"versions": {
"IdentityVerificationHub": {
"2.12.0": {
"initializerVersion": 12,
"initializerFunction": "initializeGovernance",
"changelog": "Governance upgrade - migrated to AccessControlUpgradeable with multi-tier governance",
"gitTag": "hub-v2.12.0",
"deployments": {
"celo": {
"impl": "0x05FB9D7830889cc389E88198f6A224eA87F01151",
"deployedAt": "2025-12-10T05:43:58.258Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
}
}
},
"2.11.0": {
"initializerVersion": 11,
"initializerFunction": "initialize",
"changelog": "V2 hub deployment with Ownable2StepUpgradeable governance",
"gitTag": "hub-v2.11.0",
"deployments": {
"celo": {
"impl": "",
"deployedAt": "",
"deployedBy": "",
"gitCommit": ""
}
}
}
},
"IdentityRegistry": {
"1.2.0": {
"initializerVersion": 2,
"initializerFunction": "initializeGovernance",
"changelog": "Governance upgrade - migrated to AccessControlUpgradeable",
"gitTag": "registry-passport-v1.2.0",
"deployments": {
"celo": {
"impl": "0x81E7F74560FAF7eE8DE3a36A5a68B6cbc429Cd36",
"deployedAt": "2025-12-10T05:53:12.534Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
}
}
},
"1.1.0": {
"initializerVersion": 1,
"initializerFunction": "initialize",
"changelog": "Initial deployment with Ownable2StepUpgradeable governance",
"gitTag": "registry-passport-v1.1.0",
"deployments": {
"celo": {
"impl": "",
"deployedAt": "",
"deployedBy": "",
"gitCommit": ""
}
}
}
},
"IdentityRegistryIdCard": {
"1.2.0": {
"initializerVersion": 2,
"initializerFunction": "initializeGovernance",
"changelog": "Governance upgrade - migrated to AccessControlUpgradeable",
"gitTag": "registry-idcard-v1.2.0",
"deployments": {
"celo": {
"impl": "0x7d5e4b7D4c3029aF134D50642674Af8F875118a4",
"deployedAt": "2025-12-10T05:45:56.772Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
}
}
},
"1.1.0": {
"initializerVersion": 1,
"initializerFunction": "initialize",
"changelog": "Initial deployment",
"gitTag": "registry-idcard-v1.1.0",
"deployments": {
"celo": {
"impl": "",
"deployedAt": "",
"deployedBy": "",
"gitCommit": ""
}
}
}
},
"IdentityRegistryAadhaar": {
"1.2.0": {
"initializerVersion": 2,
"initializerFunction": "initializeGovernance",
"changelog": "Governance upgrade - migrated to AccessControlUpgradeable",
"gitTag": "registry-aadhaar-v1.2.0",
"deployments": {
"celo": {
"impl": "0xbD861A9cecf7B0A9631029d55A8CE1155e50697c",
"deployedAt": "2025-12-10T05:47:22.844Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
}
}
},
"1.1.0": {
"initializerVersion": 1,
"initializerFunction": "initialize",
"changelog": "Initial deployment",
"gitTag": "registry-aadhaar-v1.1.0",
"deployments": {
"celo": {
"impl": "",
"deployedAt": "",
"deployedBy": "",
"gitCommit": ""
}
}
}
},
"PCR0Manager": {
"1.2.0": {
"initializerVersion": 0,
"initializerFunction": "",
"changelog": "Multisig governance deployment - migrated from single owner to AccessControl",
"gitTag": "pcr0manager-v1.2.0",
"deployments": {
"celo": {
"impl": "0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717",
"deployedAt": "2025-12-10T06:17:50.863Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": "5787cff3bcbea870b50eccd7164fbd45b758568e"
}
}
}
}
}
}

54
contracts/foundry.toml Normal file
View File

@@ -0,0 +1,54 @@
# Foundry Configuration for Hardhat Compatibility
# Based on: https://getfoundry.sh/config/hardhat/
[profile.default]
# Use Hardhat's directory structure
src = "contracts"
out = "out" # Keep separate from Hardhat's artifacts to avoid conflicts
libs = ["node_modules", "lib"]
test = "test/foundry"
script = "script"
cache_path = "cache_forge"
# Enable FFI for OpenZeppelin Upgrades plugin
ffi = true
ast = true
build_info = true
extra_output = ["storageLayout"]
build_info_path = "out/build-info"
# Solidity compiler settings (match Hardhat)
solc_version = "0.8.28"
optimizer = true
optimizer_runs = 200
via_ir = false
evm_version = "cancun"
# File system permissions for OpenZeppelin plugin
fs_permissions = [{ access = "read", path = "out" }]
# Linked libraries (deployed on Celo Mainnet)
libraries = [
"contracts/libraries/CustomVerifier.sol:CustomVerifier:0x9E66B82Da87309fAE1403078d498a069A30860c4",
"node_modules/poseidon-solidity/PoseidonT3.sol:PoseidonT3:0xF134707a4C4a3a76b8410fC0294d620A7c341581"
]
# Celo mainnet and testnet RPC endpoints
[rpc_endpoints]
celo = "${CELO_RPC_URL}"
celo_alfajores = "https://alfajores-forno.celo-testnet.org"
# Etherscan API configuration for contract verification
[etherscan]
celo = { key = "${CELOSCAN_API_KEY}", url = "https://api.celoscan.io/api" }
celo_alfajores = { key = "${CELOSCAN_API_KEY}", url = "https://api-alfajores.celoscan.io/api" }
# Formatting settings
[fmt]
line_length = 120
tab_width = 4
bracket_spacing = true
int_types = "long"
multiline_func_header = "all"
quote_style = "double"
number_underscore = "thousands"

View File

@@ -1,12 +1,15 @@
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@openzeppelin/hardhat-upgrades";
import dotenv from "dotenv";
dotenv.config();
import "hardhat-contract-sizer";
import "@nomicfoundation/hardhat-ignition-ethers";
import "solidity-coverage";
import "hardhat-gas-reporter";
import "hardhat-contract-sizer";
// Import custom upgrade tasks
import "./tasks/upgrade";
// Use a dummy private key for CI/local development (not used for actual deployments)
const DUMMY_PRIVATE_KEY = "0x0000000000000000000000000000000000000000000000000000000000000001";
@@ -16,9 +19,10 @@ const config: HardhatUserConfig = {
solidity: {
version: "0.8.28",
settings: {
evmVersion: "cancun",
optimizer: {
enabled: true,
runs: 100000,
runs: 200,
},
},
},
@@ -37,7 +41,7 @@ const config: HardhatUserConfig = {
chainId: 31337,
url: "http://127.0.0.1:8545",
accounts: {
mnemonic: "test test test test test test test test test test test test",
mnemonic: "test test test test test test test test test test test junk",
count: 20,
},
},

View File

@@ -71,7 +71,10 @@
"update:ofacroot": "npx dotenv-cli -- bash -c 'NETWORK=${NETWORK} npx tsx scripts/updateRegistryOfacRoot.ts'",
"update:pcr0": "npx dotenv-cli -- bash -c 'PCR0_ACTION=${PCR0_ACTION:-add} PCR0_KEY=${PCR0_KEY} yarn hardhat ignition deploy ignition/modules/scripts/updatePCR0.ts --network ${NETWORK:-localhost} --reset'",
"upgrade:hub": "npx dotenv-cli -- bash -c 'yarn hardhat ignition deploy ignition/modules/upgrade/deployNewHubAndUpgrade.ts --network ${NETWORK:-localhost} ${VERIFY:+--verify}'",
"upgrade:registry": "npx dotenv-cli -- bash -c 'yarn hardhat ignition deploy ignition/modules/upgrade/deployNewRegistryAndUpgrade.ts --network ${NETWORK:-localhost} ${VERIFY:+--verify}'"
"upgrade:registry": "npx dotenv-cli -- bash -c 'yarn hardhat ignition deploy ignition/modules/upgrade/deployNewRegistryAndUpgrade.ts --network ${NETWORK:-localhost} ${VERIFY:+--verify}'",
"upgrade": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade --network ${NETWORK:-localhost}'",
"upgrade:status": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade:status --network ${NETWORK:-localhost}'",
"upgrade:history": "yarn hardhat upgrade:history"
},
"dependencies": {
"@ashpect/smt": "https://github.com/ashpect/smt#main",
@@ -81,6 +84,9 @@
"@openpassport/zk-kit-smt": "^0.0.1",
"@openzeppelin/contracts": "5.4.0",
"@openzeppelin/contracts-upgradeable": "5.4.0",
"@safe-global/api-kit": "^4.0.1",
"@safe-global/protocol-kit": "^6.1.2",
"@safe-global/safe-core-sdk-types": "^5.1.0",
"@selfxyz/common": "workspace:^",
"@zk-kit/imt": "^2.0.0-beta.4",
"@zk-kit/imt.sol": "^2.0.0-beta.12",
@@ -103,6 +109,7 @@
"@nomicfoundation/hardhat-toolbox": "^3.0.0",
"@nomicfoundation/hardhat-verify": "^2.0.6",
"@nomicfoundation/ignition-core": "^0.15.12",
"@openzeppelin/hardhat-upgrades": "^3.9.1",
"@typechain/ethers-v6": "^0.4.3",
"@typechain/hardhat": "^8.0.3",
"@types/chai": "^4.3.16",

4
contracts/remappings.txt Normal file
View File

@@ -0,0 +1,4 @@
@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/
@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/
forge-std/=lib/forge-std/src/
openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/

View File

@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Script} from "forge-std/Script.sol";
import {console2} from "forge-std/console2.sol";
import {PCR0Manager} from "../contracts/utils/PCR0Manager.sol";
/**
* @title MigratePCR0Manager
* @notice Foundry script to deploy and initialize new PCR0Manager with AccessControl governance
*
* This script:
* 1. Deploys new PCR0Manager (V2 with AccessControl)
* 2. Adds all 7 finalized PCR0 values
* 3. Transfers roles to multisigs
* 4. Deployer renounces all roles
* 5. Verifies final state
*
* Usage:
* - Set in .env file:
* SECURITY_GOVERNANCE_ADDRESS=0x...
* OPERATIONS_GOVERNANCE_ADDRESS=0x...
* - Dry run: forge script script/MigratePCR0Manager.s.sol --fork-url $CELO_RPC_URL -vvv
* - Execute: forge script script/MigratePCR0Manager.s.sol --rpc-url https://forno.celo.org --broadcast --verify -vvv
*/
contract MigratePCR0Manager is Script {
// Governance roles
bytes32 public constant SECURITY_ROLE = keccak256("SECURITY_ROLE");
bytes32 public constant OPERATIONS_ROLE = keccak256("OPERATIONS_ROLE");
// Multisig addresses (from environment)
address securityMultisig;
address operationsMultisig;
function run() external returns (address newPCR0Manager) {
console2.log("================================================================================");
console2.log("PCR0MANAGER DEPLOYMENT: Fresh deployment with AccessControl");
console2.log("================================================================================");
console2.log("\nDeployer:", msg.sender);
console2.log("Chain ID:", block.chainid);
// Get multisig addresses from .env
securityMultisig = vm.envAddress("SECURITY_GOVERNANCE_ADDRESS");
operationsMultisig = vm.envAddress("OPERATIONS_GOVERNANCE_ADDRESS");
require(securityMultisig != address(0), "SECURITY_GOVERNANCE_ADDRESS not set in .env");
require(operationsMultisig != address(0), "OPERATIONS_GOVERNANCE_ADDRESS not set in .env");
console2.log("\nGovernance addresses:");
console2.log(" Critical Multisig:", securityMultisig);
console2.log(" Standard Multisig:", operationsMultisig);
// Get finalized PCR0 values
bytes[] memory pcr0Values = getFinalizedPCR0Values();
console2.log("\nPCR0 values to add:", pcr0Values.length);
vm.startBroadcast();
// Step 1: Deploy PCR0Manager
console2.log("\n=== Step 1: Deploy PCR0Manager ===");
PCR0Manager pcr0Manager = new PCR0Manager();
newPCR0Manager = address(pcr0Manager);
console2.log("Deployed at:", newPCR0Manager);
// Step 2: Add PCR0 values
console2.log("\n=== Step 2: Add PCR0 Values ===");
for (uint256 i = 0; i < pcr0Values.length; i++) {
pcr0Manager.addPCR0(pcr0Values[i]);
console2.log(" Added PCR0", i + 1, "of", pcr0Values.length);
}
// Step 3: Transfer roles to multisigs
console2.log("\n=== Step 3: Transfer Roles to Multisigs ===");
pcr0Manager.grantRole(SECURITY_ROLE, securityMultisig);
pcr0Manager.grantRole(OPERATIONS_ROLE, operationsMultisig);
console2.log(" Granted SECURITY_ROLE to:", securityMultisig);
console2.log(" Granted OPERATIONS_ROLE to:", operationsMultisig);
// Step 4: Deployer renounces roles
console2.log("\n=== Step 4: Deployer Renounces All Roles ===");
pcr0Manager.renounceRole(SECURITY_ROLE, msg.sender);
pcr0Manager.renounceRole(OPERATIONS_ROLE, msg.sender);
console2.log(" Deployer renounced SECURITY_ROLE");
console2.log(" Deployer renounced OPERATIONS_ROLE");
vm.stopBroadcast();
// Step 5: Verify final state
console2.log("\n=== Step 5: Verify Final State ===");
verifyFinalState(pcr0Manager, pcr0Values);
console2.log("\n================================================================================");
console2.log("DEPLOYMENT COMPLETE!");
console2.log("================================================================================");
console2.log("\nNew PCR0Manager:", newPCR0Manager);
console2.log("Total PCR0 values:", pcr0Values.length);
console2.log("Governance:");
console2.log(" Critical Multisig:", securityMultisig);
console2.log(" Standard Multisig:", operationsMultisig);
console2.log("\nNext steps:");
console2.log("1. Update Hub to point to new PCR0Manager");
console2.log("2. Update documentation with new address");
console2.log("3. Verify contract on Celoscan");
return newPCR0Manager;
}
/**
* @notice Returns finalized PCR0 values (32-byte format)
* @dev These will be padded to 48 bytes by PCR0Manager (prefixed with 16 zero bytes)
*/
function getFinalizedPCR0Values() internal pure returns (bytes[] memory) {
bytes[] memory pcr0s = new bytes[](7);
pcr0s[0] = hex"eb71776987d5f057030823f591d160c9d5d5e0a96c9a2a826778be1da2b8302a";
pcr0s[1] = hex"d2221a0ee83901980c607ceff2edbedf3f6ce5f437eafa5d89be39e9e7487c04";
pcr0s[2] = hex"4458aeb87796e92700be2d9c2984e376bce42bd80a4bf679e060d3bdaa6de119";
pcr0s[3] = hex"aa3deefa408710420e8b4ffe5b95f1dafeb4f06cb16ea44ec7353944671c660a";
pcr0s[4] = hex"b31e0df12cd52b961590796511d91a26364dd963c4aa727246b40513e470c232";
pcr0s[5] = hex"26bc53c698f78016ad7c326198d25d309d1487098af3f28fc55e951f903e9596";
pcr0s[6] = hex"b62720bdb510c2830cf9d58caa23912d0b214d6c278bf22e90942a6b69d272af";
return pcr0s;
}
/**
* @notice Verifies the final state of the deployed PCR0Manager
*/
function verifyFinalState(PCR0Manager pcr0Manager, bytes[] memory pcr0Values) internal view {
// Verify all PCR0 values are set (need 48-byte format for checking)
for (uint256 i = 0; i < pcr0Values.length; i++) {
// Pad to 48 bytes
bytes memory padded = abi.encodePacked(new bytes(16), pcr0Values[i]);
bool isSet = pcr0Manager.isPCR0Set(padded);
require(isSet, "PCR0 value not set");
}
console2.log(" [PASS] All", pcr0Values.length, "PCR0 values verified");
// Verify deployer has no roles
bool deployerHasCritical = pcr0Manager.hasRole(SECURITY_ROLE, msg.sender);
bool deployerHasStandard = pcr0Manager.hasRole(OPERATIONS_ROLE, msg.sender);
require(!deployerHasCritical, "Deployer still has SECURITY_ROLE");
require(!deployerHasStandard, "Deployer still has OPERATIONS_ROLE");
console2.log(" [PASS] Deployer has no roles");
// Verify multisigs have roles
bool criticalHasRole = pcr0Manager.hasRole(SECURITY_ROLE, securityMultisig);
bool standardHasRole = pcr0Manager.hasRole(OPERATIONS_ROLE, operationsMultisig);
require(criticalHasRole, "Critical multisig missing SECURITY_ROLE");
require(standardHasRole, "Standard multisig missing OPERATIONS_ROLE");
console2.log(" [PASS] Multisigs have correct roles");
console2.log("\n [SUCCESS] All verifications passed!");
}
}

View File

@@ -0,0 +1,179 @@
# Upgrade Tooling
A comprehensive toolset for safely upgrading UUPS proxy contracts in the Self Protocol.
## Overview
The upgrade tooling provides:
- **Safety checks** - Storage layout validation, version validation, reinitializer verification
- **Safe multisig integration** - Creates proposals for SECURITY_ROLE approval
- **Version tracking** - Automatic registry updates and git tagging
- **Audit trail** - Complete deployment history with changelogs
## Quick Start
```bash
# Single command to validate, deploy, and propose
npx hardhat upgrade --contract IdentityVerificationHub --network celo --changelog "Added feature X"
```
## The `upgrade` Command
Validates, deploys, and creates a Safe multisig proposal in one step.
```bash
npx hardhat upgrade \
--contract <ContractId> \
--network <network> \
[--changelog <message>] \
[--prepare-only]
```
**Options:**
- `--contract` - Contract to upgrade (IdentityVerificationHub, IdentityRegistry, etc.)
- `--network` - Target network (celo, sepolia, localhost)
- `--changelog` - Description of changes
- `--prepare-only` - Deploy implementation without creating Safe proposal
**What it does:**
1. ✅ Validates `@custom:version` increment
2. ✅ Checks `reinitializer(N)` matches expected version
3. ✅ Validates storage layout compatibility
4. ✅ Clears cache and compiles fresh
5. ✅ Compares bytecode (warns if unchanged)
6. ✅ Deploys new implementation
7. ✅ Updates deployment registry
8. ✅ Creates git commit and tag
9. ✅ Creates Safe proposal (or outputs manual instructions)
## Utility Commands
```bash
# Check current deployment status
npx hardhat upgrade:status --contract IdentityVerificationHub --network celo
# View version history
npx hardhat upgrade:history --contract IdentityVerificationHub
```
## Workflow
### For Developers
```
┌─────────────────────────────────────────────────────────────────────┐
│ 1. UPDATE CONTRACT CODE │
│ - Make your changes │
│ - Update @custom:version in NatSpec │
│ - Increment reinitializer(N) modifier │
│ - Add new storage fields at END of struct only │
├─────────────────────────────────────────────────────────────────────┤
│ 2. RUN: npx hardhat upgrade --contract X --network Y --changelog Z │
│ - Validates all safety checks │
│ - Deploys new implementation │
│ - Updates registry.json │
│ - Creates git commit + tag │
│ - Creates Safe proposal │
├─────────────────────────────────────────────────────────────────────┤
│ 3. MULTISIG APPROVAL │
│ - Signers review in Safe UI │
│ - Once threshold met, click Execute │
└─────────────────────────────────────────────────────────────────────┘
```
### Contract Update Pattern
```solidity
/**
* @title MyContract
* @custom:version 2.13.0 // <-- Update this
*/
contract MyContract is ImplRoot {
struct MyStorage {
uint256 existingField;
uint256 newField; // <-- Add new fields at end only
}
// Increment reinitializer(N) for each upgrade
function initialize(...) external reinitializer(13) {
// Initialize new fields if needed
MyStorage storage $ = _getMyStorage();
if ($.newField == 0) {
$.newField = defaultValue;
}
}
}
```
## Configuration
### Deployment Registry
The registry (`deployments/registry.json`) tracks:
- Proxy addresses per network
- Current versions
- Implementation history
- Git commits and tags
### Governance Configuration
Multisig addresses are configured in `deployments/registry.json`:
```json
{
"networks": {
"celo": {
"governance": {
"securityMultisig": "0x...",
"operationsMultisig": "0x...",
"securityThreshold": "3/5",
"operationsThreshold": "2/5"
}
}
}
}
```
### Environment Variables
Required for deployments:
```bash
PRIVATE_KEY=0x... # Deployer private key
CELO_RPC_URL=https://... # RPC endpoint
```
## Supported Contracts
| Contract ID | Contract Name | Type |
| ------------------------- | ----------------------------- | ---------- |
| `IdentityVerificationHub` | IdentityVerificationHubImplV2 | UUPS Proxy |
| `IdentityRegistry` | IdentityRegistryImplV1 | UUPS Proxy |
| `IdentityRegistryIdCard` | IdentityRegistryIdCardImplV1 | UUPS Proxy |
| `IdentityRegistryAadhaar` | IdentityRegistryAadhaarImplV1 | UUPS Proxy |
## Safety Checks
| Check | What it Does | Failure Behavior |
| ---------------------- | ------------------------------------------- | -------------------- |
| Version validation | Ensures semantic version increment | Blocks upgrade |
| Reinitializer check | Verifies `reinitializer(N)` matches version | Blocks upgrade |
| Storage layout | Detects breaking storage changes | Blocks upgrade |
| Bytecode comparison | Warns if code unchanged | Prompts confirmation |
| Safe role verification | Confirms Safe has SECURITY_ROLE | Blocks upgrade |
| Constructor check | Flags `_disableInitializers()` | Prompts confirmation |
## Troubleshooting
| Issue | Solution |
| ----------------------------- | ----------------------------------------- |
| "Version matches current" | Update `@custom:version` in contract |
| "Reinitializer mismatch" | Update `reinitializer(N)` to next version |
| "Storage layout incompatible" | Don't remove/reorder storage variables |
| "Safe not indexed" | Submit manually via Safe UI |
| "Bytecode unchanged" | Ensure you saved contract changes |

View File

@@ -0,0 +1,91 @@
/**
* upgrade:history task
*
* Shows deployment history for a contract.
*/
import { task, types } from "hardhat/config";
import { log, readRegistry, getContractDefinition, compareVersions, shortenAddress } from "./utils";
import { CONTRACT_IDS, ContractId } from "./types";
interface HistoryTaskArgs {
contract: ContractId;
}
task("upgrade:history", "Show deployment history for a contract")
.addParam("contract", `Contract to show history for (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.setAction(async (args: HistoryTaskArgs) => {
const { contract: contractId } = args;
log.header(`DEPLOYMENT HISTORY: ${contractId}`);
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
return;
}
const registry = readRegistry();
const contractDef = getContractDefinition(contractId);
const versions = registry.versions[contractId] || {};
// Contract info
console.log("\n📋 Contract Information");
console.log("─".repeat(60));
log.detail("Source", contractDef.source);
log.detail("Type", contractDef.type);
log.detail("Description", contractDef.description);
// Network deployments
console.log("\n🔗 Network Deployments");
console.log("─".repeat(60));
for (const [networkName, networkConfig] of Object.entries(registry.networks)) {
const deployment = networkConfig.deployments[contractId];
if (deployment) {
const address = deployment.proxy || deployment.address || "Not deployed";
const version = deployment.currentVersion || "N/A";
console.log(` ${networkName.padEnd(15)} ${shortenAddress(address).padEnd(15)} v${version}`);
}
}
// Version history
console.log("\n📜 Version History");
console.log("─".repeat(60));
const versionNumbers = Object.keys(versions).sort((a, b) => compareVersions(b, a));
if (versionNumbers.length === 0) {
console.log(" No versions recorded");
}
for (const version of versionNumbers) {
const info = versions[version];
const isCurrent = Object.values(registry.networks).some(
(n) => n.deployments[contractId]?.currentVersion === version,
);
console.log(`\n ${isCurrent ? "→" : " "} v${version} (Initializer v${info.initializerVersion})`);
if (isCurrent) {
console.log(" CURRENT");
}
console.log(" " + "─".repeat(50));
console.log(` Changelog: ${info.changelog}`);
console.log(` Initializer: ${info.initializerFunction}()`);
console.log(` Git tag: ${info.gitTag}`);
// Show deployments per network
if (info.deployments && Object.keys(info.deployments).length > 0) {
console.log(" Deployments:");
for (const [network, deployment] of Object.entries(info.deployments)) {
if (deployment.impl) {
const date = deployment.deployedAt ? new Date(deployment.deployedAt).toLocaleString() : "Unknown";
console.log(` ${network}: ${shortenAddress(deployment.impl)} (${date})`);
}
}
}
}
console.log("\n");
});
export {};

View File

@@ -0,0 +1,14 @@
/**
* Upgrade Tasks
*
* Main entry point for contract upgrade tooling.
*/
// Import all upgrade tasks
import "./upgrade";
import "./prepare";
import "./propose";
import "./status";
import "./history";
export {};

View File

@@ -0,0 +1,403 @@
/**
* upgrade:prepare task
*
* Validates and deploys a new implementation contract.
* Does NOT execute the upgrade - that requires multisig approval.
*
* Features:
* - Auto-validates version increment (must be current + 1)
* - Auto-updates @custom:version in contract if needed
* - Auto-commits after successful deployment
* - Records git commit hash and creates tag
*
* Usage:
* npx hardhat upgrade:prepare --contract IdentityVerificationHub --network celo
*/
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import {
log,
getContractDefinition,
getProxyAddress,
getCurrentVersion,
getGitCommitShort,
getGitBranch,
hasUncommittedChanges,
validateVersionIncrement,
suggestNextVersion,
readContractVersion,
updateContractVersion,
getContractFilePath,
addVersion,
getExplorerUrl,
shortenAddress,
createGitTag,
gitCommit,
getLatestVersionInfo,
} from "./utils";
import { CONTRACT_IDS, ContractId, SupportedNetwork } from "./types";
interface PrepareTaskArgs {
contract: ContractId;
newVersion?: string;
changelog?: string;
dryRun: boolean;
skipCommit: boolean;
}
task("upgrade:prepare", "Validate and deploy a new implementation contract")
.addParam("contract", `Contract to upgrade (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.addOptionalParam(
"newVersion",
"New version - auto-detected from contract file if not provided",
undefined,
types.string,
)
.addOptionalParam("changelog", "Changelog entry for this version", undefined, types.string)
.addFlag("dryRun", "Simulate the deployment without actually deploying")
.addFlag("skipCommit", "Skip auto-commit after deployment")
.setAction(async (args: PrepareTaskArgs, hre: HardhatRuntimeEnvironment) => {
const { contract: contractId, changelog, dryRun, skipCommit } = args;
let { newVersion } = args;
const network = hre.network.name as SupportedNetwork;
log.header(`UPGRADE PREPARE: ${contractId}`);
log.detail("Network", network);
log.detail("Mode", dryRun ? "DRY RUN (no actual deployment)" : "LIVE DEPLOYMENT");
// ========================================================================
// Step 1: Validate inputs
// ========================================================================
log.step("Validating inputs...");
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
log.info(`Valid contracts: ${CONTRACT_IDS.join(", ")}`);
return;
}
const contractDef = getContractDefinition(contractId);
if (contractDef.type !== "uups-proxy") {
log.error(`Contract '${contractId}' is not upgradeable (type: ${contractDef.type})`);
return;
}
let proxyAddress: string;
try {
proxyAddress = getProxyAddress(contractId, network);
} catch {
log.error(`No proxy deployed for '${contractId}' on network '${network}'`);
log.info("Deploy the proxy first using the deploy script");
return;
}
const currentVersion = getCurrentVersion(contractId, network);
log.detail("Contract source", contractDef.source);
log.detail("Proxy address", proxyAddress);
log.detail("Current version", currentVersion);
// ========================================================================
// Step 2: Determine and validate new version
// ========================================================================
log.step("Validating version...");
const contractFilePath = getContractFilePath(contractId);
const contractFileVersion = contractFilePath ? readContractVersion(contractFilePath) : null;
if (contractFileVersion) {
log.detail("Version in contract file", contractFileVersion);
}
// If no version provided, use contract file version
if (!newVersion) {
if (contractFileVersion && contractFileVersion !== currentVersion) {
const validation = validateVersionIncrement(currentVersion, contractFileVersion);
if (validation.valid) {
newVersion = contractFileVersion;
log.info(`Using version from contract file: ${newVersion}`);
} else {
log.error(`Contract file has invalid version ${contractFileVersion}`);
const suggestions = suggestNextVersion(currentVersion);
log.info(`Current version: ${currentVersion}`);
log.info(
`Valid next versions: ${suggestions.patch} (patch), ${suggestions.minor} (minor), ${suggestions.major} (major)`,
);
return;
}
} else {
log.error("Contract file version matches current - update @custom:version in contract first");
const suggestions = suggestNextVersion(currentVersion);
log.info(`Current version: ${currentVersion}`);
log.info(
`Valid next versions: ${suggestions.patch} (patch), ${suggestions.minor} (minor), ${suggestions.major} (major)`,
);
return;
}
}
// Validate version increment
const versionValidation = validateVersionIncrement(currentVersion, newVersion);
if (!versionValidation.valid) {
log.error(versionValidation.error!);
return;
}
log.success(`Version increment valid: ${currentVersion}${newVersion} (${versionValidation.type})`);
// ========================================================================
// Step 3: Update contract file version if needed
// ========================================================================
if (contractFilePath && contractFileVersion !== newVersion) {
log.step("Updating contract file version...");
if (dryRun) {
log.info(`[DRY RUN] Would update ${contractFilePath} to version ${newVersion}`);
} else {
const updated = updateContractVersion(contractFilePath, newVersion);
if (updated) {
log.success(`Updated @custom:version to ${newVersion} in contract file`);
} else {
log.warning("Could not update version in contract file - please update manually");
}
}
}
// ========================================================================
// Step 4: Check git state
// ========================================================================
log.step("Checking git state...");
const gitBranch = getGitBranch();
const uncommittedChanges = hasUncommittedChanges();
log.detail("Branch", gitBranch);
if (uncommittedChanges && !dryRun) {
log.warning("You have uncommitted changes. They will be included in the auto-commit.");
}
// ========================================================================
// Step 5: Load and validate the new implementation
// ========================================================================
log.step("Loading contract factory...");
const contractName = contractDef.source;
let ContractFactory;
try {
// Handle contracts that need library linking
if (contractName === "IdentityVerificationHubImplV2") {
const CustomVerifier = await hre.ethers.getContractFactory("CustomVerifier");
const customVerifier = await CustomVerifier.deploy();
await customVerifier.waitForDeployment();
ContractFactory = await hre.ethers.getContractFactory(contractName, {
libraries: {
CustomVerifier: await customVerifier.getAddress(),
},
});
log.info("Deployed CustomVerifier library for linking");
} else if (
contractName === "IdentityRegistryImplV1" ||
contractName === "IdentityRegistryIdCardImplV1" ||
contractName === "IdentityRegistryAadhaarImplV1"
) {
const PoseidonT3 = await hre.ethers.getContractFactory("PoseidonT3");
const poseidonT3 = await PoseidonT3.deploy();
await poseidonT3.waitForDeployment();
ContractFactory = await hre.ethers.getContractFactory(contractName, {
libraries: {
PoseidonT3: await poseidonT3.getAddress(),
},
});
log.info("Deployed PoseidonT3 library for linking");
} else {
ContractFactory = await hre.ethers.getContractFactory(contractName);
}
log.success(`Loaded contract factory: ${contractName}`);
} catch (error) {
log.error(`Failed to load contract factory: ${error}`);
return;
}
// ========================================================================
// Step 6: Validate storage layout
// ========================================================================
log.step("Validating storage layout compatibility...");
try {
await hre.upgrades.validateImplementation(ContractFactory, {
kind: "uups",
unsafeAllowLinkedLibraries: true,
unsafeAllow: ["constructor", "external-library-linking"],
});
log.success("Storage layout validation passed");
} catch (error) {
log.error(`Storage layout validation failed: ${error}`);
return;
}
// ========================================================================
// Step 7: Simulate upgrade on fork
// ========================================================================
log.step("Simulating upgrade on fork...");
try {
const proxyContract = await hre.ethers.getContractAt(contractName, proxyAddress);
const SECURITY_ROLE = await proxyContract.SECURITY_ROLE();
log.detail("SECURITY_ROLE", SECURITY_ROLE);
log.success("Fork simulation passed - proxy is accessible");
} catch (error) {
log.error(`Fork simulation failed: ${error}`);
return;
}
// ========================================================================
// Step 8: Dry run summary
// ========================================================================
if (dryRun) {
log.step("DRY RUN - Skipping actual deployment");
log.box([
"DRY RUN SUMMARY",
"─".repeat(50),
`Contract: ${contractId}`,
`Version: ${currentVersion}${newVersion}`,
`Network: ${network}`,
`Proxy: ${proxyAddress}`,
"",
"What would happen:",
`1. Update contract file to version ${newVersion}`,
"2. Deploy new implementation",
"3. Update registry.json",
"4. Create git commit and tag",
"",
"Run without --dry-run to execute.",
]);
return;
}
// ========================================================================
// Step 9: Deploy new implementation
// ========================================================================
log.step("Deploying new implementation...");
try {
const implementation = await ContractFactory.deploy();
await implementation.waitForDeployment();
const implementationAddress = await implementation.getAddress();
log.success(`Implementation deployed: ${implementationAddress}`);
log.detail("Explorer", `${getExplorerUrl(network)}/address/${implementationAddress}`);
// ========================================================================
// Step 10: Verify on block explorer
// ========================================================================
log.step("Verifying contract on block explorer...");
try {
await hre.run("verify:verify", {
address: implementationAddress,
constructorArguments: [],
});
log.success("Contract verified on block explorer");
} catch (error: any) {
if (error.message?.includes("Already Verified")) {
log.info("Contract already verified");
} else {
log.warning(`Verification failed: ${error.message}`);
log.info("You can verify manually later");
}
}
// ========================================================================
// Step 11: Update registry
// ========================================================================
log.step("Updating deployment registry...");
// Get previous version info to determine initializer version
const latestVersion = getLatestVersionInfo(contractId);
const newInitializerVersion = (latestVersion?.info.initializerVersion || 0) + 1;
const gitCommitShort = getGitCommitShort();
const deployerAddress = (await hre.ethers.provider.getSigner()).address;
addVersion(
contractId,
network,
newVersion,
{
initializerVersion: newInitializerVersion,
initializerFunction: newInitializerVersion === 1 ? "initialize" : `initializeV${newInitializerVersion}`,
changelog: changelog || `Upgrade to v${newVersion}`,
gitTag: `${contractId.toLowerCase()}-v${newVersion}`,
},
{
impl: implementationAddress,
deployedAt: new Date().toISOString(),
deployedBy: deployerAddress,
gitCommit: "", // Will be set after commit
},
);
log.success("Registry updated");
// ========================================================================
// Step 12: Auto-commit and tag
// ========================================================================
if (!skipCommit) {
log.step("Creating git commit...");
const commitMessage = `feat: ${contractId} v${newVersion} deployed on ${network.charAt(0).toUpperCase() + network.slice(1)}
- Implementation: ${implementationAddress}
- Changelog: ${changelog || "Upgrade"}`;
const committed = gitCommit(commitMessage);
if (committed) {
const newGitCommit = getGitCommitShort();
log.success(`Committed: ${newGitCommit}`);
// Try to create git tag
try {
createGitTag(
`${contractId.toLowerCase()}-v${newVersion}`,
`${contractId} v${newVersion} - ${changelog || "Upgrade"}`,
);
log.success(`Created git tag: ${contractId.toLowerCase()}-v${newVersion}`);
} catch (e) {
log.warning("Could not create git tag - you can create it manually");
}
} else {
log.warning("Could not create git commit - please commit manually");
}
}
// ========================================================================
// Summary
// ========================================================================
log.box([
"DEPLOYMENT SUCCESSFUL",
"═".repeat(50),
`Contract: ${contractId}`,
`Version: ${currentVersion}${newVersion}`,
`Network: ${network}`,
"",
"Addresses:",
` Proxy: ${shortenAddress(proxyAddress)}`,
` New Impl: ${shortenAddress(implementationAddress)}`,
"",
"Next steps:",
` 1. Run: npx hardhat upgrade:propose --contract ${contractId} --network ${network}`,
" 2. Multisig signers approve in Safe UI",
" 3. Transaction executes automatically when threshold reached",
]);
} catch (error) {
log.error(`Deployment failed: ${error}`);
return;
}
});
export {};

View File

@@ -0,0 +1,201 @@
/**
* upgrade:propose task
*
* Creates a Safe multisig transaction to execute the upgrade.
* The implementation must already be deployed via upgrade:prepare.
*
* Usage:
* npx hardhat upgrade:propose --contract IdentityVerificationHub --network celo
*/
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import {
log,
getContractDefinition,
getProxyAddress,
getCurrentVersion,
getGovernanceConfig,
getVersionInfo,
getNetworkDeployment,
} from "./utils";
import { CONTRACT_IDS, ContractId, SupportedNetwork } from "./types";
/**
* Get Safe chain prefix for URL
*/
function getChainPrefix(network: SupportedNetwork): string {
const prefixes: Record<SupportedNetwork, string> = {
celo: "celo",
"celo-sepolia": "celo",
sepolia: "sep",
localhost: "eth",
};
return prefixes[network] || network;
}
interface ProposeTaskArgs {
contract: ContractId;
dryRun: boolean;
}
task("upgrade:propose", "Create Safe transaction to execute the upgrade")
.addParam("contract", `Contract to upgrade (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.addFlag("dryRun", "Generate transaction data without submitting to Safe")
.setAction(async (args: ProposeTaskArgs, hre: HardhatRuntimeEnvironment) => {
const { contract: contractId, dryRun } = args;
const network = hre.network.name as SupportedNetwork;
log.header(`UPGRADE PROPOSE: ${contractId}`);
log.detail("Network", network);
log.detail("Mode", dryRun ? "DRY RUN (no Safe submission)" : "LIVE SUBMISSION");
// ========================================================================
// Step 1: Validate inputs
// ========================================================================
log.step("Validating inputs...");
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
return;
}
const contractDef = getContractDefinition(contractId);
if (contractDef.type !== "uups-proxy") {
log.error(`Contract '${contractId}' is not upgradeable`);
return;
}
let proxyAddress: string;
try {
proxyAddress = getProxyAddress(contractId, network);
} catch {
log.error(`No proxy deployed for '${contractId}' on '${network}'`);
return;
}
const currentVersion = getCurrentVersion(contractId, network);
const deployment = getNetworkDeployment(contractId, network);
const newImplAddress = deployment?.currentImpl;
if (!newImplAddress) {
log.error("No implementation deployed. Run upgrade:prepare first.");
return;
}
const versionInfo = getVersionInfo(contractId, currentVersion);
log.detail("Contract", contractId);
log.detail("Proxy", proxyAddress);
log.detail("Current version", currentVersion);
log.detail("New implementation", newImplAddress);
log.detail("Changelog", versionInfo?.changelog || "N/A");
// ========================================================================
// Step 2: Load governance configuration
// ========================================================================
log.step("Loading governance configuration...");
const governance = getGovernanceConfig(network);
if (!governance.securityMultisig) {
log.error(`No security multisig configured for network '${network}'`);
log.info("Update deployments/registry.json with governance addresses");
return;
}
log.detail("Security multisig", governance.securityMultisig);
log.detail("Required threshold", governance.securityThreshold);
// ========================================================================
// Step 3: Verify implementation contract
// ========================================================================
log.step("Verifying implementation contract...");
const implCode = await hre.ethers.provider.getCode(newImplAddress);
if (implCode === "0x") {
log.error(`No contract found at implementation address ${newImplAddress}`);
return;
}
log.success("Implementation contract verified on-chain");
// ========================================================================
// Step 4: Build upgrade transaction
// ========================================================================
log.step("Building upgrade transaction...");
const contractName = contractDef.source;
const proxyContract = await hre.ethers.getContractAt(contractName, proxyAddress);
// Check if there's an initializer to call
let initData = "0x";
const initializerName = versionInfo?.initializerFunction;
if (initializerName && initializerName !== "initialize") {
try {
const iface = proxyContract.interface;
const initFragment = iface.getFunction(initializerName);
if (initFragment) {
initData = iface.encodeFunctionData(initializerName, []);
log.detail("Initialization", initializerName);
}
} catch {
log.detail("Initialization", "None (function not found)");
}
} else {
log.detail("Initialization", "None");
}
// Encode upgradeToAndCall
const upgradeData = proxyContract.interface.encodeFunctionData("upgradeToAndCall", [newImplAddress, initData]);
log.detail("Method", "upgradeToAndCall(address,bytes)");
log.detail("Target", proxyAddress);
// ========================================================================
// Step 5: Output transaction data
// ========================================================================
if (dryRun) {
log.step("DRY RUN - Transaction data generated");
} else {
log.step("Generating Safe proposal data...");
}
const chainPrefix = getChainPrefix(network);
log.success("Transaction data generated");
log.box([
"SAFE PROPOSAL READY",
"═".repeat(60),
`Safe: ${governance.securityMultisig}`,
`Threshold: ${governance.securityThreshold}`,
"",
"Transaction:",
` To: ${proxyAddress}`,
" Value: 0",
` Data: ${upgradeData.slice(0, 50)}...`,
"",
"Submit via Safe UI:",
` 1. Go to: https://app.safe.global/home?safe=${chainPrefix}:${governance.securityMultisig}`,
" 2. Click 'New transaction' → 'Transaction Builder'",
" 3. Enter: To, Value, Data from above",
` 4. ${governance.securityThreshold} signers approve`,
" 5. Upgrade executes automatically!",
]);
// Output raw data for copy-paste
console.log("\n📋 Raw transaction data (copy this for Transaction Builder):");
console.log("─".repeat(60));
console.log(
JSON.stringify(
{
to: proxyAddress,
value: "0",
data: upgradeData,
},
null,
2,
),
);
});
export {};

View File

@@ -0,0 +1,113 @@
/**
* upgrade:status task
*
* Shows current deployment status for a contract.
*/
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import {
log,
getContractDefinition,
getNetworkDeployment,
getGovernanceConfig,
getVersionInfo,
shortenAddress,
getExplorerUrl,
} from "./utils";
import { CONTRACT_IDS, ContractId, SupportedNetwork } from "./types";
interface StatusTaskArgs {
contract: ContractId;
}
task("upgrade:status", "Show current deployment status for a contract")
.addParam("contract", `Contract to check (${CONTRACT_IDS.join(", ")})`, undefined, types.string)
.setAction(async (args: StatusTaskArgs, hre: HardhatRuntimeEnvironment) => {
const { contract: contractId } = args;
const network = hre.network.name as SupportedNetwork;
log.header(`STATUS: ${contractId} on ${network}`);
if (!CONTRACT_IDS.includes(contractId as ContractId)) {
log.error(`Invalid contract: ${contractId}`);
return;
}
const contractDef = getContractDefinition(contractId);
const deployment = getNetworkDeployment(contractId, network);
const governance = getGovernanceConfig(network);
console.log("\n📋 Contract Info");
console.log("─".repeat(60));
log.detail("Source", contractDef.source);
log.detail("Type", contractDef.type);
if (!deployment) {
log.warning(`Not deployed on ${network}`);
return;
}
const currentVersion = deployment.currentVersion;
const proxyAddress = deployment.proxy || deployment.address;
const implAddress = deployment.currentImpl;
const versionInfo = currentVersion ? getVersionInfo(contractId, currentVersion) : null;
console.log("\n🔗 Deployment");
console.log("─".repeat(60));
log.detail("Proxy", proxyAddress || "N/A");
log.detail("Implementation", implAddress || "N/A");
log.detail("Current version", currentVersion || "N/A");
if (proxyAddress) {
log.detail("Explorer", `${getExplorerUrl(network)}/address/${proxyAddress}`);
}
if (versionInfo) {
console.log("\n📌 Version Info");
console.log("─".repeat(60));
log.detail("Changelog", versionInfo.changelog);
log.detail("Initializer", versionInfo.initializerFunction);
log.detail("Git tag", versionInfo.gitTag);
}
console.log("\n🔐 Governance");
console.log("─".repeat(60));
log.detail("Security multisig", governance.securityMultisig || "Not configured");
log.detail("Security threshold", governance.securityThreshold);
log.detail("Operations multisig", governance.operationsMultisig || "Not configured");
log.detail("Operations threshold", governance.operationsThreshold);
// Check on-chain state if connected
if (proxyAddress && contractDef.type === "uups-proxy") {
try {
const proxy = await hre.ethers.getContractAt(contractDef.source, proxyAddress);
console.log("\n⛓ On-Chain State");
console.log("─".repeat(60));
// Try to read version
try {
const onChainVersion = await proxy.version();
log.detail("On-chain version", onChainVersion);
} catch {
log.detail("On-chain version", "N/A");
}
// Check role holders
try {
const SECURITY_ROLE = await proxy.SECURITY_ROLE();
const OPERATIONS_ROLE = await proxy.OPERATIONS_ROLE();
log.detail("SECURITY_ROLE", shortenAddress(SECURITY_ROLE));
log.detail("OPERATIONS_ROLE", shortenAddress(OPERATIONS_ROLE));
} catch {
// Not all contracts have these
}
} catch (error) {
log.warning(`Could not read on-chain state: ${error}`);
}
}
console.log("\n");
});
export {};

View File

@@ -0,0 +1,29 @@
/**
* Types for upgrade tooling
*/
export const SUPPORTED_NETWORKS = ["celo", "celo-sepolia", "sepolia", "localhost"] as const;
export type SupportedNetwork = (typeof SUPPORTED_NETWORKS)[number];
// Contract IDs match registry keys
export const CONTRACT_IDS = [
"IdentityVerificationHub",
"IdentityRegistry",
"IdentityRegistryIdCard",
"IdentityRegistryAadhaar",
"PCR0Manager",
"VerifyAll",
"DummyContract",
] as const;
export type ContractId = (typeof CONTRACT_IDS)[number];
// Re-export types from utils for convenience
export type {
ContractDefinition,
NetworkConfig,
NetworkDeployment,
GovernanceConfig,
VersionInfo,
VersionDeployment,
DeploymentRegistry,
} from "./utils";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,631 @@
/**
* Utility functions for upgrade tooling
*
* Works with the new registry structure:
* - contracts: Contract definitions (source, type, description)
* - networks: Per-network deployments and governance
* - versions: Version history with deployment details
*/
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
import { SupportedNetwork } from "./types";
// Registry types matching the new structure
export interface ContractDefinition {
source: string;
type: "uups-proxy" | "non-upgradeable";
description: string;
}
export interface NetworkDeployment {
proxy?: string;
address?: string;
currentVersion: string;
currentImpl?: string;
}
export interface GovernanceConfig {
securityMultisig: string;
operationsMultisig: string;
securityThreshold: string;
operationsThreshold: string;
}
export interface NetworkConfig {
chainId: number;
governance: GovernanceConfig;
deployments: Record<string, NetworkDeployment>;
}
export interface VersionDeployment {
impl: string;
deployedAt: string;
deployedBy: string;
gitCommit: string;
}
export interface VersionInfo {
initializerVersion: number;
initializerFunction: string;
changelog: string;
gitTag: string;
deployments: Record<string, VersionDeployment>;
}
export interface DeploymentRegistry {
$schema: string;
lastUpdated: string;
contracts: Record<string, ContractDefinition>;
networks: Record<string, NetworkConfig>;
versions: Record<string, Record<string, VersionInfo>>;
}
// Console colors for pretty output
const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
gray: "\x1b[90m",
};
export const log = {
info: (msg: string) => console.log(`${colors.blue}${colors.reset} ${msg}`),
success: (msg: string) => console.log(`${colors.green}${colors.reset} ${msg}`),
warning: (msg: string) => console.log(`${colors.yellow}⚠️${colors.reset} ${msg}`),
error: (msg: string) => console.log(`${colors.red}${colors.reset} ${msg}`),
step: (msg: string) => console.log(`\n${colors.magenta}🔄${colors.reset} ${colors.bold}${msg}${colors.reset}`),
header: (msg: string) =>
console.log(
`\n${colors.cyan}${"═".repeat(70)}${colors.reset}\n${colors.bold}${msg}${colors.reset}\n${colors.cyan}${"═".repeat(70)}${colors.reset}`,
),
detail: (label: string, value: string) => console.log(` ${colors.gray}${label}:${colors.reset} ${value}`),
box: (lines: string[]) => {
const maxLen = Math.max(...lines.map((l) => l.length));
console.log(`\n┌${"─".repeat(maxLen + 2)}`);
lines.forEach((line) => console.log(`${line.padEnd(maxLen)}`));
console.log(`${"─".repeat(maxLen + 2)}\n`);
},
};
/**
* Get the path to the deployment registry
*/
export function getRegistryPath(): string {
return path.join(__dirname, "../../deployments/registry.json");
}
/**
* Read the deployment registry
*/
export function readRegistry(): DeploymentRegistry {
const registryPath = getRegistryPath();
if (!fs.existsSync(registryPath)) {
throw new Error(`Deployment registry not found at ${registryPath}`);
}
return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
}
/**
* Write the deployment registry
*/
export function writeRegistry(registry: DeploymentRegistry): void {
const registryPath = getRegistryPath();
registry.lastUpdated = new Date().toISOString();
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + "\n");
}
/**
* Get contract definition from registry
*/
export function getContractDefinition(contractId: string): ContractDefinition {
const registry = readRegistry();
const contract = registry.contracts[contractId];
if (!contract) {
throw new Error(`Contract '${contractId}' not found in registry`);
}
return contract;
}
/**
* Get network config
*/
export function getNetworkConfig(network: SupportedNetwork): NetworkConfig {
const registry = readRegistry();
const networkConfig = registry.networks[network];
if (!networkConfig) {
throw new Error(`Network '${network}' not configured in registry`);
}
return networkConfig;
}
/**
* Get deployment for a contract on a network
*/
export function getNetworkDeployment(contractId: string, network: SupportedNetwork): NetworkDeployment | null {
const registry = readRegistry();
return registry.networks[network]?.deployments?.[contractId] || null;
}
/**
* Get proxy address for a contract on a network
*/
export function getProxyAddress(contractId: string, network: SupportedNetwork): string {
const deployment = getNetworkDeployment(contractId, network);
if (!deployment?.proxy) {
throw new Error(`No proxy address found for '${contractId}' on network '${network}'`);
}
return deployment.proxy;
}
/**
* Get current version for a contract on a network
*/
export function getCurrentVersion(contractId: string, network: SupportedNetwork): string {
const deployment = getNetworkDeployment(contractId, network);
return deployment?.currentVersion || "0.0.0";
}
/**
* Get governance config for a network
*/
export function getGovernanceConfig(network: SupportedNetwork): GovernanceConfig {
const networkConfig = getNetworkConfig(network);
return networkConfig.governance;
}
/**
* Get version info from registry
*/
export function getVersionInfo(contractId: string, version: string): VersionInfo | null {
const registry = readRegistry();
return registry.versions[contractId]?.[version] || null;
}
/**
* Get latest version info for a contract
*/
export function getLatestVersionInfo(contractId: string): { version: string; info: VersionInfo } | null {
const registry = readRegistry();
const versions = registry.versions[contractId];
if (!versions) return null;
const versionNumbers = Object.keys(versions).sort((a, b) => compareVersions(b, a));
if (versionNumbers.length === 0) return null;
return { version: versionNumbers[0], info: versions[versionNumbers[0]] };
}
/**
* Add new version to registry
*/
export function addVersion(
contractId: string,
network: SupportedNetwork,
version: string,
versionInfo: Omit<VersionInfo, "deployments">,
deployment: VersionDeployment,
): void {
const registry = readRegistry();
// Initialize versions object if needed
if (!registry.versions[contractId]) {
registry.versions[contractId] = {};
}
// Add or update version info
if (!registry.versions[contractId][version]) {
registry.versions[contractId][version] = {
...versionInfo,
deployments: {},
};
}
registry.versions[contractId][version].deployments[network] = deployment;
// Update network deployment
if (!registry.networks[network]) {
throw new Error(`Network '${network}' not configured`);
}
if (!registry.networks[network].deployments[contractId]) {
registry.networks[network].deployments[contractId] = {
proxy: "",
currentVersion: "",
currentImpl: "",
};
}
registry.networks[network].deployments[contractId].currentVersion = version;
registry.networks[network].deployments[contractId].currentImpl = deployment.impl;
writeRegistry(registry);
}
/**
* Update gitCommit for a specific version deployment
*/
export function updateVersionGitCommit(
contractId: string,
network: SupportedNetwork,
version: string,
gitCommit: string,
): void {
const registry = readRegistry();
if (!registry.versions[contractId]?.[version]?.deployments?.[network]) {
throw new Error(`Deployment not found: ${contractId} v${version} on ${network}`);
}
registry.versions[contractId][version].deployments[network].gitCommit = gitCommit;
writeRegistry(registry);
}
/**
* Update proxy address for a contract on a network
*/
export function setProxyAddress(contractId: string, network: SupportedNetwork, proxyAddress: string): void {
const registry = readRegistry();
if (!registry.networks[network]) {
throw new Error(`Network '${network}' not configured`);
}
if (!registry.networks[network].deployments[contractId]) {
registry.networks[network].deployments[contractId] = {
proxy: "",
currentVersion: "",
currentImpl: "",
};
}
registry.networks[network].deployments[contractId].proxy = proxyAddress;
writeRegistry(registry);
}
/**
* Get current git commit hash
*/
export function getGitCommit(): string {
try {
return execSync("git rev-parse HEAD").toString().trim();
} catch {
return "unknown";
}
}
/**
* Get short git commit hash
*/
export function getGitCommitShort(): string {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch {
return "unknown";
}
}
/**
* Get current git branch
*/
export function getGitBranch(): string {
try {
return execSync("git rev-parse --abbrev-ref HEAD").toString().trim();
} catch {
return "unknown";
}
}
/**
* Check if there are uncommitted changes
*/
export function hasUncommittedChanges(): boolean {
try {
const status = execSync("git status --porcelain").toString().trim();
return status.length > 0;
} catch {
return false;
}
}
/**
* Create a git tag
*/
export function createGitTag(tag: string, message: string): void {
execSync(`git tag -a ${tag} -m "${message}"`);
}
/**
* Parse semantic version
*/
export function parseVersion(version: string): { major: number; minor: number; patch: number } {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`Invalid version format: ${version}`);
}
return {
major: parseInt(match[1]),
minor: parseInt(match[2]),
patch: parseInt(match[3]),
};
}
/**
* Compare versions (returns 1 if a > b, -1 if a < b, 0 if equal)
*/
export function compareVersions(a: string, b: string): number {
const va = parseVersion(a);
const vb = parseVersion(b);
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
return 0;
}
/**
* Increment version
*/
export function incrementVersion(version: string, type: "major" | "minor" | "patch"): string {
const v = parseVersion(version);
switch (type) {
case "major":
return `${v.major + 1}.0.0`;
case "minor":
return `${v.major}.${v.minor + 1}.0`;
case "patch":
return `${v.major}.${v.minor}.${v.patch + 1}`;
}
}
/**
* Suggest next version based on current version
*/
export function suggestNextVersion(currentVersion: string): {
patch: string;
minor: string;
major: string;
} {
const v = parseVersion(currentVersion);
return {
patch: `${v.major}.${v.minor}.${v.patch + 1}`,
minor: `${v.major}.${v.minor + 1}.0`,
major: `${v.major + 1}.0.0`,
};
}
/**
* Validate that new version is a valid increment of current version
*/
export function validateVersionIncrement(
currentVersion: string,
newVersion: string,
): {
valid: boolean;
type: "patch" | "minor" | "major" | null;
error?: string;
} {
try {
const current = parseVersion(currentVersion);
const next = parseVersion(newVersion);
// Check if it's a valid increment
if (next.major === current.major + 1 && next.minor === 0 && next.patch === 0) {
return { valid: true, type: "major" };
}
if (next.major === current.major && next.minor === current.minor + 1 && next.patch === 0) {
return { valid: true, type: "minor" };
}
if (next.major === current.major && next.minor === current.minor && next.patch === current.patch + 1) {
return { valid: true, type: "patch" };
}
// Not a valid increment
const suggested = suggestNextVersion(currentVersion);
return {
valid: false,
type: null,
error: `Invalid version increment. Current: ${currentVersion}, Got: ${newVersion}. Valid options: ${suggested.patch} (patch), ${suggested.minor} (minor), ${suggested.major} (major)`,
};
} catch (e) {
return { valid: false, type: null, error: `Invalid version format: ${e}` };
}
}
/**
* Read version from contract file's @custom:version
*/
export function readContractVersion(contractPath: string): string | null {
try {
const content = fs.readFileSync(contractPath, "utf-8");
const match = content.match(/@custom:version\s+(\d+\.\d+\.\d+)/);
return match ? match[1] : null;
} catch {
return null;
}
}
/**
* Read reinitializer version from contract's initialize function
* Looks for patterns like: reinitializer(N) or initializer
* Returns the highest reinitializer version found, or 1 if only initializer is found
*/
export function readReinitializerVersion(contractPath: string): number | null {
try {
const content = fs.readFileSync(contractPath, "utf-8");
// Find all reinitializer(N) occurrences
const reinitMatches = content.matchAll(/reinitializer\s*\(\s*(\d+)\s*\)/g);
const versions: number[] = [];
for (const match of reinitMatches) {
versions.push(parseInt(match[1]));
}
if (versions.length > 0) {
// Return the highest version found
return Math.max(...versions);
}
// Check for basic initializer modifier (equivalent to reinitializer(1))
if (content.match(/\binitializer\b/) && !content.match(/reinitializer/)) {
return 1;
}
return null;
} catch {
return null;
}
}
/**
* Validate that reinitializer version matches expected version
* Expected version = previous initializer version + 1
*/
export function validateReinitializerVersion(
contractPath: string,
expectedVersion: number,
): { valid: boolean; actual: number | null; error?: string } {
const actual = readReinitializerVersion(contractPath);
if (actual === null) {
return {
valid: false,
actual: null,
error: "Could not find reinitializer/initializer modifier in contract",
};
}
if (actual !== expectedVersion) {
return {
valid: false,
actual,
error: `Reinitializer version mismatch. Expected: reinitializer(${expectedVersion}), Found: reinitializer(${actual})`,
};
}
return { valid: true, actual };
}
/**
* Update version in contract file's @custom:version
*/
export function updateContractVersion(contractPath: string, newVersion: string): boolean {
try {
let content = fs.readFileSync(contractPath, "utf-8");
const originalContent = content;
// Update @custom:version
content = content.replace(/@custom:version\s+\d+\.\d+\.\d+/, `@custom:version ${newVersion}`);
// Also update version() function if it exists
content = content.replace(/function version\(\)[^}]+return\s+"(\d+\.\d+\.\d+)"/, (match) =>
match.replace(/"\d+\.\d+\.\d+"/, `"${newVersion}"`),
);
if (content !== originalContent) {
fs.writeFileSync(contractPath, content);
return true;
}
return false;
} catch {
return false;
}
}
/**
* Get contract file path from contract ID
*/
export function getContractFilePath(contractId: string): string | null {
const contract = getContractDefinition(contractId);
const contractName = contract.source;
// Common paths to check
const possiblePaths = [
path.join(__dirname, `../../contracts/${contractName}.sol`),
path.join(__dirname, `../../contracts/tests/${contractName}.sol`),
path.join(__dirname, `../../contracts/registry/${contractName}.sol`),
path.join(__dirname, `../../contracts/utils/${contractName}.sol`),
path.join(__dirname, `../../contracts/sdk/${contractName}.sol`),
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
return null;
}
/**
* Create a git commit
*/
export function gitCommit(message: string): boolean {
try {
execSync(`git add -A && git commit -m "${message}"`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}
/**
* Format address for display (shortened)
*/
export function shortenAddress(address: string): string {
if (!address || address.length < 10) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
/**
* Get Safe API URL for a network
*/
export function getSafeApiUrl(network: SupportedNetwork): string {
// Safe Transaction Service API URLs (must end with /api/)
const urls: Record<SupportedNetwork, string> = {
celo: "https://safe-transaction-celo.safe.global/api/",
"celo-sepolia": "https://safe-transaction-celo.safe.global/api/", // Celo testnet uses same as mainnet
sepolia: "https://safe-transaction-sepolia.safe.global/api/",
localhost: "", // No Safe service for localhost
};
return urls[network];
}
/**
* Get block explorer URL for a network
*/
export function getExplorerUrl(network: SupportedNetwork): string {
const urls: Record<SupportedNetwork, string> = {
celo: "https://celoscan.io",
"celo-sepolia": "https://celo-sepolia.blockscout.com",
sepolia: "https://sepolia.etherscan.io",
localhost: "http://localhost:8545", // No explorer for localhost
};
return urls[network];
}
/**
* Get all contract IDs from registry
*/
export function getContractIds(): string[] {
const registry = readRegistry();
return Object.keys(registry.contracts);
}
/**
* Check if contract is deployed on network
*/
export function isDeployedOnNetwork(contractId: string, network: SupportedNetwork): boolean {
const deployment = getNetworkDeployment(contractId, network);
if (!deployment) return false;
const contract = getContractDefinition(contractId);
if (contract.type === "uups-proxy") {
return !!deployment.proxy;
}
return !!deployment.address;
}

View File

@@ -0,0 +1,158 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {PCR0Manager} from "../../contracts/utils/PCR0Manager.sol";
/**
* @title MigratePCR0ManagerTest
* @notice Test for deploying PCR0Manager V2 with AccessControl governance
*
* This test:
* 1. Deploys new PCR0Manager with AccessControl
* 2. Adds all 7 finalized PCR0 values
* 3. Transfers roles to multisigs
* 4. Verifies deployer has no control after transfer
*
* Run with:
* forge test --match-contract MigratePCR0ManagerTest -vvv
*/
contract MigratePCR0ManagerTest is Test {
// Test accounts
address deployer;
address securityMultisig;
address operationsMultisig;
address unauthorized;
// Contracts
PCR0Manager pcr0Manager;
// Governance roles
bytes32 public constant SECURITY_ROLE = keccak256("SECURITY_ROLE");
bytes32 public constant OPERATIONS_ROLE = keccak256("OPERATIONS_ROLE");
// Finalized PCR0 values (32-byte format)
bytes[] pcr0Values;
function setUp() public {
console2.log("================================================================================");
console2.log("PCR0Manager DEPLOYMENT TEST: AccessControl Governance");
console2.log("================================================================================");
// Set up test accounts
deployer = makeAddr("deployer");
securityMultisig = makeAddr("securityMultisig");
operationsMultisig = makeAddr("operationsMultisig");
unauthorized = makeAddr("unauthorized");
vm.deal(deployer, 100 ether);
console2.log("Deployer:", deployer);
console2.log("Critical Multisig:", securityMultisig);
console2.log("Standard Multisig:", operationsMultisig);
// Populate finalized PCR0 values (32-byte format)
pcr0Values.push(hex"eb71776987d5f057030823f591d160c9d5d5e0a96c9a2a826778be1da2b8302a");
pcr0Values.push(hex"d2221a0ee83901980c607ceff2edbedf3f6ce5f437eafa5d89be39e9e7487c04");
pcr0Values.push(hex"4458aeb87796e92700be2d9c2984e376bce42bd80a4bf679e060d3bdaa6de119");
pcr0Values.push(hex"aa3deefa408710420e8b4ffe5b95f1dafeb4f06cb16ea44ec7353944671c660a");
pcr0Values.push(hex"b31e0df12cd52b961590796511d91a26364dd963c4aa727246b40513e470c232");
pcr0Values.push(hex"26bc53c698f78016ad7c326198d25d309d1487098af3f28fc55e951f903e9596");
pcr0Values.push(hex"b62720bdb510c2830cf9d58caa23912d0b214d6c278bf22e90942a6b69d272af");
}
function testDeploymentWorkflow() public {
console2.log("\n=== Step 1: Deploy PCR0Manager ===");
vm.startPrank(deployer);
pcr0Manager = new PCR0Manager();
vm.stopPrank();
console2.log("Deployed at:", address(pcr0Manager));
assertTrue(pcr0Manager.hasRole(SECURITY_ROLE, deployer), "Deployer missing SECURITY_ROLE");
assertTrue(pcr0Manager.hasRole(OPERATIONS_ROLE, deployer), "Deployer missing OPERATIONS_ROLE");
console2.log("\n=== Step 2: Add PCR0 Values ===");
// Add PCR0 values
vm.startPrank(deployer);
for (uint256 i = 0; i < pcr0Values.length; i++) {
pcr0Manager.addPCR0(pcr0Values[i]);
}
vm.stopPrank();
console2.log("Added", pcr0Values.length, "PCR0 values");
// Verify all PCR0s are set (check with 48-byte format)
for (uint256 i = 0; i < pcr0Values.length; i++) {
bytes memory pcr0_48 = abi.encodePacked(new bytes(16), pcr0Values[i]);
assertTrue(pcr0Manager.isPCR0Set(pcr0_48), "PCR0 not set");
}
console2.log("\n=== Step 3: Test Governance ===");
// Test add/remove functionality
vm.startPrank(deployer);
bytes memory testPCR0_32 = hex"1111111111111111111111111111111111111111111111111111111111111111";
bytes memory testPCR0_48 = abi.encodePacked(new bytes(16), testPCR0_32);
pcr0Manager.addPCR0(testPCR0_32);
assertTrue(pcr0Manager.isPCR0Set(testPCR0_48), "Test PCR0 not added");
pcr0Manager.removePCR0(testPCR0_32);
assertFalse(pcr0Manager.isPCR0Set(testPCR0_48), "Test PCR0 not removed");
vm.stopPrank();
// Unauthorized user blocked
vm.startPrank(unauthorized);
vm.expectRevert();
pcr0Manager.addPCR0(testPCR0_32);
vm.stopPrank();
console2.log("Governance working correctly");
console2.log("\n=== Step 4: Transfer Roles to Multisigs ===");
vm.startPrank(deployer);
pcr0Manager.grantRole(SECURITY_ROLE, securityMultisig);
pcr0Manager.grantRole(OPERATIONS_ROLE, operationsMultisig);
pcr0Manager.renounceRole(SECURITY_ROLE, deployer);
pcr0Manager.renounceRole(OPERATIONS_ROLE, deployer);
vm.stopPrank();
console2.log("Roles transferred to multisigs");
console2.log("\n=== Step 5: Verify Final State ===");
// Deployer has no roles
assertFalse(pcr0Manager.hasRole(SECURITY_ROLE, deployer), "Deployer still has SECURITY_ROLE");
assertFalse(pcr0Manager.hasRole(OPERATIONS_ROLE, deployer), "Deployer still has OPERATIONS_ROLE");
// Multisigs have roles
assertTrue(pcr0Manager.hasRole(SECURITY_ROLE, securityMultisig), "Critical multisig missing SECURITY_ROLE");
assertTrue(
pcr0Manager.hasRole(OPERATIONS_ROLE, operationsMultisig),
"Standard multisig missing OPERATIONS_ROLE"
);
// Multisig can manage, deployer cannot
vm.startPrank(securityMultisig);
bytes memory testPCR0_32_v2 = hex"2222222222222222222222222222222222222222222222222222222222222222";
bytes memory testPCR0_48_v2 = abi.encodePacked(new bytes(16), testPCR0_32_v2);
pcr0Manager.addPCR0(testPCR0_32_v2);
assertTrue(pcr0Manager.isPCR0Set(testPCR0_48_v2), "Multisig cannot add PCR0");
pcr0Manager.removePCR0(testPCR0_32_v2);
vm.stopPrank();
vm.startPrank(deployer);
vm.expectRevert();
pcr0Manager.addPCR0(testPCR0_32_v2);
vm.stopPrank();
console2.log("Multisigs have full control");
console2.log("Deployer has ZERO control");
console2.log("\n================================================================================");
console2.log("DEPLOYMENT TEST PASSED - Ready for production");
console2.log("================================================================================");
}
}

View File

@@ -0,0 +1,355 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {Upgrades, Options} from "openzeppelin-foundry-upgrades/Upgrades.sol";
import {IdentityVerificationHubImplV2} from "../../contracts/IdentityVerificationHubImplV2.sol";
import {IdentityRegistryImplV1} from "../../contracts/registry/IdentityRegistryImplV1.sol";
import {IdentityRegistryIdCardImplV1} from "../../contracts/registry/IdentityRegistryIdCardImplV1.sol";
import {IdentityRegistryAadhaarImplV1} from "../../contracts/registry/IdentityRegistryAadhaarImplV1.sol";
import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
/**
* @title UpgradeToAccessControlTest
* @notice Fork test for upgrading contracts from Ownable to AccessControl
*
* This test:
* 1. Forks Celo mainnet at current block
* 2. Captures pre-upgrade state from real deployed contracts
* 3. Executes upgrades to AccessControl governance
* 4. Verifies ALL state is preserved (no storage collisions)
* 5. Tests governance functionality
* 6. Simulates role transfer to multisigs
* 7. Verifies deployer has no control after transfer
*
* Run with:
* forge test --match-contract UpgradeToAccessControlTest --fork-url $CELO_RPC_URL -vvv
*/
contract UpgradeToAccessControlTest is Test {
// ============================================================================
// DEPLOYED CONTRACT ADDRESSES (Celo Mainnet)
// ============================================================================
address constant HUB_PROXY = 0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF;
address constant REGISTRY_PASSPORT_PROXY = 0x37F5CB8cB1f6B00aa768D8aA99F1A9289802A968;
address constant REGISTRY_ID_CARD_PROXY = 0xeAD1E6Ec29c1f3D33a0662f253a3a94D189566E1;
address constant REGISTRY_AADHAAR_PROXY = 0xd603Fa8C8f4694E8DD1DcE1f27C0C3fc91e32Ac4;
address constant CUSTOM_VERIFIER = 0x9E66B82Da87309fAE1403078d498a069A30860c4;
address constant POSEIDON_T3 = 0xF134707a4C4a3a76b8410fC0294d620A7c341581;
// Test accounts
address deployer;
address securityMultisig;
address operationsMultisig;
// Contracts
IdentityVerificationHubImplV2 hub;
IdentityRegistryImplV1 passportRegistry;
IdentityRegistryIdCardImplV1 idCardRegistry;
IdentityRegistryAadhaarImplV1 aadhaarRegistry;
// Governance roles
bytes32 public constant SECURITY_ROLE = keccak256("SECURITY_ROLE");
bytes32 public constant OPERATIONS_ROLE = keccak256("OPERATIONS_ROLE");
// Pre-upgrade state - captures ALL publicly accessible critical state variables
struct PreUpgradeState {
// Hub state (4 variables)
address hubRegistryPassport;
address hubRegistryIdCard;
address hubRegistryAadhaar;
uint256 hubAadhaarWindow;
// Passport Registry state (6 variables)
uint256 passportIdentityRoot;
uint256 passportDscKeyRoot;
uint256 passportPassportNoOfacRoot;
uint256 passportNameDobOfacRoot;
uint256 passportNameYobOfacRoot;
uint256 passportCscaRoot;
// ID Card Registry state (5 variables)
uint256 idCardIdentityRoot;
uint256 idCardDscKeyRoot;
uint256 idCardNameDobOfacRoot;
uint256 idCardNameYobOfacRoot;
uint256 idCardCscaRoot;
// Aadhaar Registry state (3 variables)
uint256 aadhaarIdentityRoot;
uint256 aadhaarNameDobOfacRoot;
uint256 aadhaarNameYobOfacRoot;
}
PreUpgradeState preState;
function setUp() public {
console2.log("================================================================================");
console2.log("CELO MAINNET FORK TEST: Ownable -> AccessControl Upgrade");
console2.log("================================================================================");
// Initialize contract references to get current owner
hub = IdentityVerificationHubImplV2(HUB_PROXY);
passportRegistry = IdentityRegistryImplV1(REGISTRY_PASSPORT_PROXY);
idCardRegistry = IdentityRegistryIdCardImplV1(REGISTRY_ID_CARD_PROXY);
aadhaarRegistry = IdentityRegistryAadhaarImplV1(REGISTRY_AADHAAR_PROXY);
// Get the actual current owner from the deployed contracts
deployer = Ownable2StepUpgradeable(address(hub)).owner();
// Set up multisig accounts for testing role transfer
securityMultisig = makeAddr("securityMultisig");
operationsMultisig = makeAddr("operationsMultisig");
vm.deal(deployer, 100 ether);
console2.log("Current Owner (will execute upgrade):", deployer);
console2.log("Critical Multisig (will receive roles):", securityMultisig);
console2.log("Standard Multisig (will receive roles):", operationsMultisig);
}
function testFullUpgradeWorkflow() public {
console2.log("\n=== Phase 1: Capture Pre-Upgrade State (ALL Accessible State Variables) ===");
// Hub state (4 variables)
preState.hubRegistryPassport = hub.registry(bytes32("e_passport"));
preState.hubRegistryIdCard = hub.registry(bytes32("eu_id_card"));
preState.hubRegistryAadhaar = hub.registry(bytes32("aadhaar"));
preState.hubAadhaarWindow = hub.AADHAAR_REGISTRATION_WINDOW();
// Passport Registry state (6 variables)
preState.passportIdentityRoot = passportRegistry.getIdentityCommitmentMerkleRoot();
preState.passportDscKeyRoot = passportRegistry.getDscKeyCommitmentMerkleRoot();
preState.passportPassportNoOfacRoot = passportRegistry.getPassportNoOfacRoot();
preState.passportNameDobOfacRoot = passportRegistry.getNameAndDobOfacRoot();
preState.passportNameYobOfacRoot = passportRegistry.getNameAndYobOfacRoot();
preState.passportCscaRoot = passportRegistry.getCscaRoot();
// ID Card Registry state (5 variables)
preState.idCardIdentityRoot = idCardRegistry.getIdentityCommitmentMerkleRoot();
preState.idCardDscKeyRoot = idCardRegistry.getDscKeyCommitmentMerkleRoot();
preState.idCardNameDobOfacRoot = idCardRegistry.getNameAndDobOfacRoot();
preState.idCardNameYobOfacRoot = idCardRegistry.getNameAndYobOfacRoot();
preState.idCardCscaRoot = idCardRegistry.getCscaRoot();
// Aadhaar Registry state (3 variables)
preState.aadhaarIdentityRoot = aadhaarRegistry.getIdentityCommitmentMerkleRoot();
preState.aadhaarNameDobOfacRoot = aadhaarRegistry.getNameAndDobOfacRoot();
preState.aadhaarNameYobOfacRoot = aadhaarRegistry.getNameAndYobOfacRoot();
console2.log("Captured Hub state: 4 variables");
console2.log("Captured Passport Registry state: 6 variables");
console2.log("Captured ID Card Registry state: 5 variables");
console2.log("Captured Aadhaar Registry state: 3 variables");
console2.log("Total state variables captured: 18");
console2.log("\n=== Phase 2: Execute Upgrades ===");
vm.startPrank(deployer);
// Upgrade Hub
console2.log("Upgrading Hub...");
Options memory hubOpts;
// Skip ALL OpenZeppelin checks because:
// 1. We're changing base contracts (Ownable->AccessControl) which confuses the validator
// 2. ERC-7201 namespaced storage prevents any collision
// 3. We COMPREHENSIVELY verify safety in this test:
// - Phase 3: State preservation (no data loss)
// - Phase 3.5: Library linkage (same addresses)
// - Phase 4-6: Governance functionality (roles work correctly)
hubOpts.unsafeSkipAllChecks = true;
Upgrades.upgradeProxy(
HUB_PROXY,
"IdentityVerificationHubImplV2.sol",
abi.encodeCall(IdentityVerificationHubImplV2.initializeGovernance, ()),
hubOpts
);
// Upgrade Passport Registry
console2.log("Upgrading Passport Registry...");
Options memory passportOpts;
passportOpts.unsafeSkipAllChecks = true; // Safe: verified in test phases 3-6
Upgrades.upgradeProxy(
REGISTRY_PASSPORT_PROXY,
"IdentityRegistryImplV1.sol:IdentityRegistryImplV1",
abi.encodeCall(IdentityRegistryImplV1.initializeGovernance, ()),
passportOpts
);
// Upgrade ID Card Registry
console2.log("Upgrading ID Card Registry...");
Options memory idCardOpts;
idCardOpts.unsafeSkipAllChecks = true; // Safe: verified in test phases 3-6
Upgrades.upgradeProxy(
REGISTRY_ID_CARD_PROXY,
"IdentityRegistryIdCardImplV1.sol:IdentityRegistryIdCardImplV1",
abi.encodeCall(IdentityRegistryIdCardImplV1.initializeGovernance, ()),
idCardOpts
);
// Upgrade Aadhaar Registry
console2.log("Upgrading Aadhaar Registry...");
Options memory aadhaarOpts;
aadhaarOpts.unsafeSkipAllChecks = true; // Safe: verified in test phases 3-6
Upgrades.upgradeProxy(
REGISTRY_AADHAAR_PROXY,
"IdentityRegistryAadhaarImplV1.sol:IdentityRegistryAadhaarImplV1",
abi.encodeCall(IdentityRegistryAadhaarImplV1.initializeGovernance, ()),
aadhaarOpts
);
vm.stopPrank();
console2.log("All upgrades completed");
console2.log("\n=== Phase 3: Verify State Preservation (ALL 18 State Variables) ===");
// Hub state verification (4 variables)
assertEq(hub.registry(bytes32("e_passport")), preState.hubRegistryPassport, "Hub passport registry changed");
assertEq(hub.registry(bytes32("eu_id_card")), preState.hubRegistryIdCard, "Hub ID card registry changed");
assertEq(hub.registry(bytes32("aadhaar")), preState.hubRegistryAadhaar, "Hub aadhaar registry changed");
assertEq(hub.AADHAAR_REGISTRATION_WINDOW(), preState.hubAadhaarWindow, "Hub aadhaar window changed");
console2.log("Hub state: 4/4 variables preserved");
// Passport Registry state verification (6 variables)
assertEq(
passportRegistry.getIdentityCommitmentMerkleRoot(),
preState.passportIdentityRoot,
"Passport identity root changed"
);
assertEq(
passportRegistry.getDscKeyCommitmentMerkleRoot(),
preState.passportDscKeyRoot,
"Passport DSC key root changed"
);
assertEq(
passportRegistry.getPassportNoOfacRoot(),
preState.passportPassportNoOfacRoot,
"Passport passport# OFAC root changed"
);
assertEq(
passportRegistry.getNameAndDobOfacRoot(),
preState.passportNameDobOfacRoot,
"Passport name+DOB OFAC root changed"
);
assertEq(
passportRegistry.getNameAndYobOfacRoot(),
preState.passportNameYobOfacRoot,
"Passport name+YOB OFAC root changed"
);
assertEq(passportRegistry.getCscaRoot(), preState.passportCscaRoot, "Passport CSCA root changed");
console2.log("Passport Registry: 6/6 variables preserved");
// ID Card Registry state verification (5 variables)
assertEq(
idCardRegistry.getIdentityCommitmentMerkleRoot(),
preState.idCardIdentityRoot,
"ID Card identity root changed"
);
assertEq(
idCardRegistry.getDscKeyCommitmentMerkleRoot(),
preState.idCardDscKeyRoot,
"ID Card DSC key root changed"
);
assertEq(
idCardRegistry.getNameAndDobOfacRoot(),
preState.idCardNameDobOfacRoot,
"ID Card name+DOB OFAC root changed"
);
assertEq(
idCardRegistry.getNameAndYobOfacRoot(),
preState.idCardNameYobOfacRoot,
"ID Card name+YOB OFAC root changed"
);
assertEq(idCardRegistry.getCscaRoot(), preState.idCardCscaRoot, "ID Card CSCA root changed");
console2.log("ID Card Registry: 5/5 variables preserved");
// Aadhaar Registry state verification (3 variables)
assertEq(
aadhaarRegistry.getIdentityCommitmentMerkleRoot(),
preState.aadhaarIdentityRoot,
"Aadhaar identity root changed"
);
assertEq(
aadhaarRegistry.getNameAndDobOfacRoot(),
preState.aadhaarNameDobOfacRoot,
"Aadhaar name+DOB OFAC root changed"
);
assertEq(
aadhaarRegistry.getNameAndYobOfacRoot(),
preState.aadhaarNameYobOfacRoot,
"Aadhaar name+YOB OFAC root changed"
);
console2.log("Aadhaar Registry: 3/3 variables preserved");
console2.log("TOTAL: 18/18 state variables VERIFIED - NO storage collisions!");
console2.log("\n=== Phase 4: Verify Governance Roles ===");
// Deployer should have both roles initially
assertTrue(hub.hasRole(SECURITY_ROLE, deployer), "Deployer missing SECURITY_ROLE on Hub");
assertTrue(hub.hasRole(OPERATIONS_ROLE, deployer), "Deployer missing OPERATIONS_ROLE on Hub");
assertTrue(passportRegistry.hasRole(SECURITY_ROLE, deployer), "Deployer missing SECURITY_ROLE on Passport");
assertTrue(passportRegistry.hasRole(OPERATIONS_ROLE, deployer), "Deployer missing OPERATIONS_ROLE on Passport");
assertTrue(idCardRegistry.hasRole(SECURITY_ROLE, deployer), "Deployer missing SECURITY_ROLE on ID Card");
assertTrue(idCardRegistry.hasRole(OPERATIONS_ROLE, deployer), "Deployer missing OPERATIONS_ROLE on ID Card");
console2.log("Deployer has all required roles");
console2.log("\n=== Phase 5: Transfer Roles to Multisigs ===");
vm.startPrank(deployer);
// Grant roles to multisigs
hub.grantRole(SECURITY_ROLE, securityMultisig);
hub.grantRole(OPERATIONS_ROLE, operationsMultisig);
passportRegistry.grantRole(SECURITY_ROLE, securityMultisig);
passportRegistry.grantRole(OPERATIONS_ROLE, operationsMultisig);
idCardRegistry.grantRole(SECURITY_ROLE, securityMultisig);
idCardRegistry.grantRole(OPERATIONS_ROLE, operationsMultisig);
// Deployer renounces roles
hub.renounceRole(SECURITY_ROLE, deployer);
hub.renounceRole(OPERATIONS_ROLE, deployer);
passportRegistry.renounceRole(SECURITY_ROLE, deployer);
passportRegistry.renounceRole(OPERATIONS_ROLE, deployer);
idCardRegistry.renounceRole(SECURITY_ROLE, deployer);
idCardRegistry.renounceRole(OPERATIONS_ROLE, deployer);
vm.stopPrank();
console2.log("Roles transferred to multisigs");
console2.log("\n=== Phase 6: Verify Final State ===");
// Deployer should have NO roles
assertFalse(hub.hasRole(SECURITY_ROLE, deployer), "Deployer still has SECURITY_ROLE on Hub");
assertFalse(hub.hasRole(OPERATIONS_ROLE, deployer), "Deployer still has OPERATIONS_ROLE on Hub");
// Multisigs should have roles
assertTrue(hub.hasRole(SECURITY_ROLE, securityMultisig), "Critical multisig missing SECURITY_ROLE on Hub");
assertTrue(
hub.hasRole(OPERATIONS_ROLE, operationsMultisig),
"Standard multisig missing OPERATIONS_ROLE on Hub"
);
assertTrue(
passportRegistry.hasRole(SECURITY_ROLE, securityMultisig),
"Critical multisig missing SECURITY_ROLE on Passport"
);
assertTrue(
passportRegistry.hasRole(OPERATIONS_ROLE, operationsMultisig),
"Standard multisig missing OPERATIONS_ROLE on Passport"
);
assertTrue(
idCardRegistry.hasRole(SECURITY_ROLE, securityMultisig),
"Critical multisig missing SECURITY_ROLE on ID Card"
);
assertTrue(
idCardRegistry.hasRole(OPERATIONS_ROLE, operationsMultisig),
"Standard multisig missing OPERATIONS_ROLE on ID Card"
);
console2.log("Multisigs have full control");
console2.log("Deployer has ZERO control");
console2.log("\n================================================================================");
console2.log("UPGRADE TEST PASSED - Safe to execute on mainnet");
console2.log("================================================================================");
}
}

View File

@@ -2,27 +2,55 @@ import { expect } from "chai";
import { BigNumberish, TransactionReceipt } from "ethers";
import { ethers } from "hardhat";
import { poseidon2 } from "poseidon-lite";
import { createHash } from "crypto";
import { CIRCUIT_CONSTANTS, DscVerifierId, RegisterVerifierId } from "@selfxyz/common/constants/constants";
import { formatCountriesList, reverseBytes } from "@selfxyz/common/utils/circuits/formatInputs";
import { castFromScope } from "@selfxyz/common/utils/circuits/uuid";
import { ATTESTATION_ID } from "../utils/constants";
import { deploySystemFixtures } from "../utils/deployment";
import { deploySystemFixturesV2 } from "../utils/deploymentV2";
import BalanceTree from "../utils/example/balance-tree";
import { Formatter } from "../utils/formatter";
import { generateDscProof, generateRegisterProof, generateVcAndDiscloseProof } from "../utils/generateProof";
import { LeanIMT } from "@openpassport/zk-kit-lean-imt";
import serialized_dsc_tree from "../../../common/pubkeys/serialized_dsc_tree.json";
import { DeployedActors, VcAndDiscloseHubProof } from "../utils/types";
import { DeployedActorsV2 } from "../utils/types";
import { generateRandomFieldElement, splitHexFromBack } from "../utils/utils";
// Helper function to calculate user identifier hash
function calculateUserIdentifierHash(userContextData: string): string {
const sha256Hash = createHash("sha256")
.update(Buffer.from(userContextData.slice(2), "hex"))
.digest();
const ripemdHash = createHash("ripemd160").update(sha256Hash).digest();
return "0x" + ripemdHash.toString("hex").padStart(40, "0");
}
// Helper function to create V2 proof data format for verifySelfProof
function createV2ProofData(proof: any, userAddress: string, userData: string = "airdrop-user-data") {
const destChainId = ethers.zeroPadValue(ethers.toBeHex(31337), 32);
const userContextData = ethers.solidityPacked(
["bytes32", "bytes32", "bytes"],
[destChainId, ethers.zeroPadValue(userAddress, 32), ethers.toUtf8Bytes(userData)],
);
const attestationId = ethers.zeroPadValue(ethers.toBeHex(BigInt(ATTESTATION_ID.E_PASSPORT)), 32);
const encodedProof = ethers.AbiCoder.defaultAbiCoder().encode(
["tuple(uint256[2] a, uint256[2][2] b, uint256[2] c, uint256[] pubSignals)"],
[[proof.a, proof.b, proof.c, proof.pubSignals]],
);
const proofData = ethers.solidityPacked(["bytes32", "bytes"], [attestationId, encodedProof]);
return { proofData, userContextData };
}
describe("End to End Tests", function () {
this.timeout(0);
let deployedActors: DeployedActors;
let deployedActors: DeployedActorsV2;
let snapshotId: string;
before(async () => {
deployedActors = await deploySystemFixtures();
deployedActors = await deploySystemFixturesV2();
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
@@ -32,7 +60,10 @@ describe("End to End Tests", function () {
});
it("register dsc key commitment, register identity commitment, verify commitment and disclose attrs and claim airdrop", async () => {
const { hub, registry, mockPassport, owner, user1 } = deployedActors;
const { hub, registry, mockPassport, owner, user1, testSelfVerificationRoot, poseidonT3 } = deployedActors;
// V2 hub requires attestationId as bytes32
const attestationIdBytes32 = ethers.zeroPadValue(ethers.toBeHex(BigInt(ATTESTATION_ID.E_PASSPORT)), 32);
// register dsc key
// To increase test performance, we will just set one dsc key with groth16 proof
@@ -45,7 +76,11 @@ describe("End to End Tests", function () {
if (BigInt(dscKeys[0][i]) == dscProof.pubSignals[CIRCUIT_CONSTANTS.DSC_TREE_LEAF_INDEX]) {
const previousRoot = await registry.getDscKeyCommitmentMerkleRoot();
const previousSize = await registry.getDscKeyCommitmentTreeSize();
registerDscTx = await hub.registerDscKeyCommitment(DscVerifierId.dsc_sha256_rsa_65537_4096, dscProof);
registerDscTx = await hub.registerDscKeyCommitment(
attestationIdBytes32,
DscVerifierId.dsc_sha256_rsa_65537_4096,
dscProof,
);
const receipt = (await registerDscTx.wait()) as TransactionReceipt;
const event = receipt?.logs.find(
(log) => log.topics[0] === registry.interface.getEvent("DscKeyCommitmentRegistered").topicHash,
@@ -90,7 +125,8 @@ describe("End to End Tests", function () {
const imt = new LeanIMT<bigint>(hashFunction);
await imt.insert(BigInt(registerProof.pubSignals[CIRCUIT_CONSTANTS.REGISTER_COMMITMENT_INDEX]));
const tx = await hub.registerPassportCommitment(
const tx = await hub.registerCommitment(
attestationIdBytes32,
RegisterVerifierId.register_sha256_sha256_sha256_rsa_65537_4096,
registerProof,
);
@@ -104,7 +140,7 @@ describe("End to End Tests", function () {
registerProof.pubSignals[CIRCUIT_CONSTANTS.REGISTER_COMMITMENT_INDEX],
);
const identityNullifier = await registry.nullifiers(
ATTESTATION_ID.E_PASSPORT,
attestationIdBytes32,
registerProof.pubSignals[CIRCUIT_CONSTANTS.REGISTER_NULLIFIER_INDEX],
);
@@ -134,11 +170,25 @@ describe("End to End Tests", function () {
reverseBytes(Formatter.bytesToHexString(new Uint8Array(formatCountriesList(forbiddenCountriesList)))),
);
// Get the scope from testSelfVerificationRoot
const testRootScope = await testSelfVerificationRoot.scope();
// Calculate user identifier hash for verification
const destChainId = ethers.zeroPadValue(ethers.toBeHex(31337), 32);
const user1Address = await user1.getAddress();
const userData = ethers.toUtf8Bytes("test-user-data");
const tempUserContextData = ethers.solidityPacked(
["bytes32", "bytes32", "bytes"],
[destChainId, ethers.zeroPadValue(user1Address, 32), userData],
);
const userIdentifierHash = calculateUserIdentifierHash(tempUserContextData);
// Generate proof for V2 verification
const vcAndDiscloseProof = await generateVcAndDiscloseProof(
registerSecret,
BigInt(ATTESTATION_ID.E_PASSPORT).toString(),
mockPassport,
"test-scope",
testRootScope.toString(),
new Array(88).fill("1"),
"1",
imt,
@@ -148,59 +198,114 @@ describe("End to End Tests", function () {
undefined,
undefined,
forbiddenCountriesList,
(await user1.getAddress()).slice(2),
userIdentifierHash,
);
const vcAndDiscloseHubProof: VcAndDiscloseHubProof = {
// Set up verification config for testSelfVerificationRoot
const verificationConfigV2 = {
olderThanEnabled: true,
olderThan: "20",
forbiddenCountriesEnabled: true,
forbiddenCountriesListPacked: countriesListPacked,
forbiddenCountriesListPacked: countriesListPacked as [BigNumberish, BigNumberish, BigNumberish, BigNumberish],
ofacEnabled: [true, true, true] as [boolean, boolean, boolean],
vcAndDiscloseProof: vcAndDiscloseProof,
};
const result = await hub.verifyVcAndDisclose(vcAndDiscloseHubProof);
await testSelfVerificationRoot.setVerificationConfig(verificationConfigV2);
expect(result.identityCommitmentRoot).to.equal(
vcAndDiscloseProof.pubSignals[CIRCUIT_CONSTANTS.VC_AND_DISCLOSE_MERKLE_ROOT_INDEX],
// Create V2 proof format and verify via testSelfVerificationRoot
const { proofData, userContextData: verifyUserContextData } = createV2ProofData(
vcAndDiscloseProof,
user1Address,
"test-user-data",
);
expect(result.revealedDataPacked).to.have.lengthOf(3);
expect(result.nullifier).to.equal(vcAndDiscloseProof.pubSignals[CIRCUIT_CONSTANTS.VC_AND_DISCLOSE_NULLIFIER_INDEX]);
expect(result.attestationId).to.equal(
vcAndDiscloseProof.pubSignals[CIRCUIT_CONSTANTS.VC_AND_DISCLOSE_ATTESTATION_ID_INDEX],
// Reset test state before verification
await testSelfVerificationRoot.resetTestState();
// Verify the proof through V2 architecture
await testSelfVerificationRoot.connect(user1).verifySelfProof(proofData, verifyUserContextData);
// Check verification was successful
expect(await testSelfVerificationRoot.verificationSuccessful()).to.equal(true);
// Get the verification output and verify it
const lastOutput = await testSelfVerificationRoot.lastOutput();
expect(lastOutput).to.not.equal("0x");
// Verify attestationId matches both the expected bytes32 and the proof pubSignals
expect(lastOutput.attestationId).to.equal(attestationIdBytes32);
expect(lastOutput.attestationId).to.equal(
ethers.zeroPadValue(
ethers.toBeHex(vcAndDiscloseProof.pubSignals[CIRCUIT_CONSTANTS.VC_AND_DISCLOSE_ATTESTATION_ID_INDEX]),
32,
),
);
expect(result.userIdentifier).to.equal(
vcAndDiscloseProof.pubSignals[CIRCUIT_CONSTANTS.VC_AND_DISCLOSE_USER_IDENTIFIER_INDEX],
// Verify nullifier matches the proof pubSignals
expect(lastOutput.nullifier).to.equal(
vcAndDiscloseProof.pubSignals[CIRCUIT_CONSTANTS.VC_AND_DISCLOSE_NULLIFIER_INDEX],
);
expect(result.scope).to.equal(vcAndDiscloseProof.pubSignals[CIRCUIT_CONSTANTS.VC_AND_DISCLOSE_SCOPE_INDEX]);
for (let i = 0; i < 4; i++) {
expect(result.forbiddenCountriesListPacked[i]).to.equal(BigInt(countriesListPacked[i]));
}
// Verify userIdentifier is set
expect(lastOutput.userIdentifier).to.not.equal(0n);
// Verify olderThan value
expect(lastOutput.olderThan).to.equal(20n);
const tokenFactory = await ethers.getContractFactory("AirdropToken");
const token = await tokenFactory.connect(owner).deploy();
await token.waitForDeployment();
const airdropFactory = await ethers.getContractFactory("Airdrop");
const airdrop = await airdropFactory.connect(owner).deploy(
hub.target,
castFromScope("test-scope"),
ATTESTATION_ID.E_PASSPORT,
token.target,
true,
20,
// @ts-expect-error
true,
countriesListPacked as [BigNumberish, BigNumberish, BigNumberish, BigNumberish],
[true, true, true],
);
const airdrop = await airdropFactory.connect(owner).deploy(hub.target, "test-scope", token.target);
await airdrop.waitForDeployment();
// Set up verification config for the airdrop
const configTx = await hub.connect(owner).setVerificationConfigV2(verificationConfigV2);
const configReceipt = await configTx.wait();
const configId = configReceipt!.logs[0].topics[1];
// Set the config ID in the airdrop contract
await airdrop.connect(owner).setConfigId(configId);
await token.connect(owner).mint(airdrop.target, BigInt(1000000000000000000));
// Generate proof with the airdrop's actual scope
const airdropScope = await airdrop.scope();
// Calculate the user identifier hash for the airdrop proof
const airdropUserData = ethers.toUtf8Bytes("airdrop-user-data");
const airdropTempUserContextData = ethers.solidityPacked(
["bytes32", "bytes32", "bytes"],
[destChainId, ethers.zeroPadValue(user1Address, 32), airdropUserData],
);
const airdropUserIdentifierHash = calculateUserIdentifierHash(airdropTempUserContextData);
const airdropVcAndDiscloseProof = await generateVcAndDiscloseProof(
registerSecret,
BigInt(ATTESTATION_ID.E_PASSPORT).toString(),
mockPassport,
airdropScope.toString(),
new Array(88).fill("1"),
"1",
imt,
"20",
undefined,
undefined,
undefined,
undefined,
forbiddenCountriesList,
airdropUserIdentifierHash,
);
await airdrop.connect(owner).openRegistration();
await airdrop.connect(user1).verifySelfProof(vcAndDiscloseProof);
// Create V2 proof format for verifySelfProof
const { proofData: airdropProofData, userContextData: airdropUserContextData } = createV2ProofData(
airdropVcAndDiscloseProof,
await user1.getAddress(),
);
await airdrop.connect(user1).verifySelfProof(airdropProofData, airdropUserContextData);
await airdrop.connect(owner).closeRegistration();
const tree = new BalanceTree([{ account: await user1.getAddress(), amount: BigInt(1000000000000000000) }]);
@@ -228,19 +333,13 @@ describe("End to End Tests", function () {
const isClaimed = await airdrop.claimed(await user1.getAddress());
expect(isClaimed).to.be.true;
const readableData = await hub.getReadableRevealedData(
[result.revealedDataPacked[0], result.revealedDataPacked[1], result.revealedDataPacked[2]],
["0", "1", "2", "3", "4", "5", "6", "7", "8"],
);
expect(readableData[0]).to.equal("FRA");
expect(readableData[1]).to.deep.equal(["ALPHONSE HUGHUES ALBERT", "DUPONT"]);
expect(readableData[2]).to.equal("15AA81234");
expect(readableData[3]).to.equal("FRA");
expect(readableData[4]).to.equal("31-01-94");
expect(readableData[5]).to.equal("M");
expect(readableData[6]).to.equal("31-10-40");
expect(readableData[7]).to.equal(20n);
expect(readableData[8]).to.equal(1n);
// Verify disclosed attributes from lastOutput
expect(lastOutput.issuingState).to.equal("FRA");
expect(lastOutput.idNumber).to.equal("15AA81234");
expect(lastOutput.nationality).to.equal("FRA");
expect(lastOutput.dateOfBirth).to.equal("31-01-94");
expect(lastOutput.gender).to.equal("M");
expect(lastOutput.expiryDate).to.equal("31-10-40");
expect(lastOutput.olderThan).to.equal(20n);
});
});

View File

@@ -11,8 +11,9 @@ import { BigNumberish } from "ethers";
import { generateRandomFieldElement, getStartOfDayTimestamp, splitHexFromBack } from "../utils/utils";
import { Formatter, CircuitAttributeHandler } from "../utils/formatter";
import { formatCountriesList, reverseBytes, reverseCountryBytes } from "@selfxyz/common/utils/circuits/formatInputs";
import { getPackedForbiddenCountries } from "@selfxyz/common/utils/sanctions";
import { getPackedForbiddenCountries } from "@selfxyz/common/utils/contracts/forbiddenCountries";
import { countries, Country3LetterCode } from "@selfxyz/common/constants/countries";
import { castFromScope } from "@selfxyz/common/utils/circuits/uuid";
import path from "path";
describe("VC and Disclose", () => {
@@ -100,7 +101,7 @@ describe("VC and Disclose", () => {
registerSecret,
BigInt(ATTESTATION_ID.E_PASSPORT).toString(),
deployedActors.mockPassport,
"test-scope",
castFromScope("test-scope"),
new Array(88).fill("1"),
"1",
imt,
@@ -110,7 +111,7 @@ describe("VC and Disclose", () => {
undefined,
undefined,
forbiddenCountriesList,
(await deployedActors.user1.getAddress()).slice(2),
await deployedActors.user1.getAddress(),
);
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
@@ -439,6 +440,7 @@ describe("VC and Disclose", () => {
const { hub, registry, owner, mockPassport } = deployedActors;
const hashFunction = (a: bigint, b: bigint) => poseidon2([a, b]);
const LeanIMT = await import("@openpassport/zk-kit-lean-imt").then((mod) => mod.LeanIMT);
const imt = new LeanIMT<bigint>(hashFunction);
imt.insert(BigInt(commitment));
@@ -448,7 +450,7 @@ describe("VC and Disclose", () => {
registerSecret,
BigInt(ATTESTATION_ID.E_PASSPORT).toString(),
mockPassport,
"test-scope",
castFromScope("test-scope"),
new Array(88).fill("1"),
"1",
imt,
@@ -746,43 +748,60 @@ describe("VC and Disclose", () => {
it("should parse forbidden countries with CircuitAttributeHandler", async () => {
const { hub } = deployedActors;
const forbiddenCountriesListPacked = splitHexFromBack(
reverseCountryBytes(Formatter.bytesToHexString(new Uint8Array(formatCountriesList(forbiddenCountriesList)))),
);
const localForbiddenCountriesList = ["AFG", "ABC", "CBA"] as const;
const forbiddenCountriesListPacked = getPackedForbiddenCountries([...localForbiddenCountriesList]);
const readableForbiddenCountries = await hub.getReadableForbiddenCountries(forbiddenCountriesListPacked);
expect(readableForbiddenCountries[0]).to.equal(forbiddenCountriesList[0]);
expect(readableForbiddenCountries[1]).to.equal(forbiddenCountriesList[1]);
expect(readableForbiddenCountries[2]).to.equal(forbiddenCountriesList[2]);
expect(readableForbiddenCountries[0]).to.equal(localForbiddenCountriesList[0]);
expect(readableForbiddenCountries[1]).to.equal(localForbiddenCountriesList[1]);
expect(readableForbiddenCountries[2]).to.equal(localForbiddenCountriesList[2]);
});
it("should return maximum length of forbidden countries", async () => {
const { hub } = deployedActors;
const forbiddenCountriesList = ["AAA", "FRA", "CBA", "CBA", "CBA", "CBA", "CBA", "CBA", "CBA", "CBA"];
const forbiddenCountriesListPacked = splitHexFromBack(
reverseCountryBytes(Formatter.bytesToHexString(new Uint8Array(formatCountriesList(forbiddenCountriesList)))),
);
const localForbiddenCountriesList = [
"AAA",
"FRA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
] as const;
const forbiddenCountriesListPacked = getPackedForbiddenCountries([...localForbiddenCountriesList]);
const readableForbiddenCountries = await hub.getReadableForbiddenCountries(forbiddenCountriesListPacked);
expect(readableForbiddenCountries.length).to.equal(40);
expect(readableForbiddenCountries[0]).to.equal(forbiddenCountriesList[0]);
expect(readableForbiddenCountries[1]).to.equal(forbiddenCountriesList[1]);
expect(readableForbiddenCountries[2]).to.equal(forbiddenCountriesList[2]);
expect(readableForbiddenCountries[3]).to.equal(forbiddenCountriesList[3]);
expect(readableForbiddenCountries[4]).to.equal(forbiddenCountriesList[4]);
expect(readableForbiddenCountries[5]).to.equal(forbiddenCountriesList[5]);
expect(readableForbiddenCountries[6]).to.equal(forbiddenCountriesList[6]);
expect(readableForbiddenCountries[7]).to.equal(forbiddenCountriesList[7]);
expect(readableForbiddenCountries[8]).to.equal(forbiddenCountriesList[8]);
expect(readableForbiddenCountries[9]).to.equal(forbiddenCountriesList[9]);
expect(readableForbiddenCountries[0]).to.equal(localForbiddenCountriesList[0]);
expect(readableForbiddenCountries[1]).to.equal(localForbiddenCountriesList[1]);
expect(readableForbiddenCountries[2]).to.equal(localForbiddenCountriesList[2]);
expect(readableForbiddenCountries[3]).to.equal(localForbiddenCountriesList[3]);
expect(readableForbiddenCountries[4]).to.equal(localForbiddenCountriesList[4]);
expect(readableForbiddenCountries[5]).to.equal(localForbiddenCountriesList[5]);
expect(readableForbiddenCountries[6]).to.equal(localForbiddenCountriesList[6]);
expect(readableForbiddenCountries[7]).to.equal(localForbiddenCountriesList[7]);
expect(readableForbiddenCountries[8]).to.equal(localForbiddenCountriesList[8]);
expect(readableForbiddenCountries[9]).to.equal(localForbiddenCountriesList[9]);
});
it("should fail when getReadableForbiddenCountries is called by non-proxy", async () => {
const { hubImpl } = deployedActors;
const forbiddenCountriesList = ["AAA", "FRA", "CBA", "CBA", "CBA", "CBA", "CBA", "CBA", "CBA", "CBA"];
const forbiddenCountriesListPacked = splitHexFromBack(
reverseCountryBytes(Formatter.bytesToHexString(new Uint8Array(formatCountriesList(forbiddenCountriesList)))),
);
const localForbiddenCountriesList = [
"AAA",
"FRA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
"CBA",
] as const;
const forbiddenCountriesListPacked = getPackedForbiddenCountries([...localForbiddenCountriesList]);
await expect(hubImpl.getReadableForbiddenCountries(forbiddenCountriesListPacked)).to.be.revertedWithCustomError(
hubImpl,
"UUPSUnauthorizedCallContext",

View File

@@ -10,6 +10,7 @@ import { poseidon2 } from "poseidon-lite";
import { generateVcAndDiscloseProof, parseSolidityCalldata } from "../utils/generateProof";
import { Formatter } from "../utils/formatter";
import { formatCountriesList, reverseBytes } from "@selfxyz/common/utils/circuits/formatInputs";
import { stringToBigInt } from "@selfxyz/common/utils/scope";
import { VerifyAll } from "../../typechain-types";
import { getSMTs } from "../utils/generateProof";
import { Groth16Proof, PublicSignals, groth16 } from "snarkjs";
@@ -102,7 +103,7 @@ describe("VerifyAll", () => {
registerSecret,
BigInt(ATTESTATION_ID.E_PASSPORT).toString(),
deployedActors.mockPassport,
"test-scope",
stringToBigInt("test-scope").toString(),
new Array(88).fill("1"),
"1",
imt,
@@ -112,7 +113,7 @@ describe("VerifyAll", () => {
undefined,
undefined,
forbiddenCountriesList,
(await deployedActors.user1.getAddress()).slice(2),
await deployedActors.user1.getAddress(),
);
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
@@ -293,7 +294,7 @@ describe("VerifyAll", () => {
registerSecret,
BigInt(ATTESTATION_ID.E_PASSPORT).toString(),
deployedActors.mockPassport,
"test-scope",
stringToBigInt("test-scope").toString(),
new Array(88).fill("1"),
"1",
imt,
@@ -460,7 +461,7 @@ describe("VerifyAll", () => {
const newHubAddress = await deployedActors.user1.getAddress();
await expect(verifyAll.connect(deployedActors.user1).setHub(newHubAddress)).to.be.revertedWithCustomError(
verifyAll,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
});
@@ -468,7 +469,7 @@ describe("VerifyAll", () => {
const newRegistryAddress = await deployedActors.user1.getAddress();
await expect(
verifyAll.connect(deployedActors.user1).setRegistry(newRegistryAddress),
).to.be.revertedWithCustomError(verifyAll, "OwnableUnauthorizedAccount");
).to.be.revertedWithCustomError(verifyAll, "AccessControlUnauthorizedAccount");
});
});

View File

@@ -357,7 +357,7 @@ describe("Unit Tests for IdentityRegistry", () => {
await expect(registry.connect(user1).updateHub(newHubAddress)).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
});
@@ -394,7 +394,7 @@ describe("Unit Tests for IdentityRegistry", () => {
expect(await registry.getNameAndYobOfacRoot()).to.equal(yobRoot);
});
it("should not update OFAC root if caller is not owner", async () => {
it("should not update OFAC root if caller does not have OPERATIONS_ROLE", async () => {
const { registry, user1 } = deployedActors;
const passportRoot = generateRandomFieldElement();
const dobRoot = generateRandomFieldElement();
@@ -402,15 +402,15 @@ describe("Unit Tests for IdentityRegistry", () => {
await expect(registry.connect(user1).updatePassportNoOfacRoot(passportRoot)).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
await expect(registry.connect(user1).updateNameAndDobOfacRoot(dobRoot)).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
await expect(registry.connect(user1).updateNameAndYobOfacRoot(yobRoot)).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
});
@@ -443,13 +443,13 @@ describe("Unit Tests for IdentityRegistry", () => {
expect(await registry.getCscaRoot()).to.equal(newCscaRoot);
});
it("should not update CSCA root if caller is not owner", async () => {
it("should not update CSCA root if caller does not have OPERATIONS_ROLE", async () => {
const { registry, user1 } = deployedActors;
const newCscaRoot = generateRandomFieldElement();
await expect(registry.connect(user1).updateCscaRoot(newCscaRoot)).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
});
@@ -498,7 +498,7 @@ describe("Unit Tests for IdentityRegistry", () => {
await expect(
registry.connect(user1).devAddIdentityCommitment(attestationId, nullifier, commitment),
).to.be.revertedWithCustomError(registry, "OwnableUnauthorizedAccount");
).to.be.revertedWithCustomError(registry, "AccessControlUnauthorizedAccount");
});
it("should not add commitment if caller is not proxy", async () => {
@@ -546,7 +546,7 @@ describe("Unit Tests for IdentityRegistry", () => {
const newCommitment = generateRandomFieldElement();
await expect(
registry.connect(user1).devUpdateCommitment(commitment, newCommitment, []),
).to.be.revertedWithCustomError(registry, "OwnableUnauthorizedAccount");
).to.be.revertedWithCustomError(registry, "AccessControlUnauthorizedAccount");
});
it("should not update commitment if caller is not proxy", async () => {
@@ -592,7 +592,7 @@ describe("Unit Tests for IdentityRegistry", () => {
await registry.devAddIdentityCommitment(attestationId, nullifier, commitment);
await expect(registry.connect(user1).devRemoveCommitment(commitment, [])).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
});
@@ -632,7 +632,7 @@ describe("Unit Tests for IdentityRegistry", () => {
const dscCommitment = generateRandomFieldElement();
await expect(registry.connect(user1).devAddDscKeyCommitment(dscCommitment)).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
});
@@ -673,7 +673,7 @@ describe("Unit Tests for IdentityRegistry", () => {
await registry.devAddDscKeyCommitment(dscCommitment);
await expect(
registry.connect(user1).devUpdateDscKeyCommitment(dscCommitment, newDscCommitment, []),
).to.be.revertedWithCustomError(registry, "OwnableUnauthorizedAccount");
).to.be.revertedWithCustomError(registry, "AccessControlUnauthorizedAccount");
});
it("should not update dsc key commitment if caller is not proxy", async () => {
@@ -711,7 +711,7 @@ describe("Unit Tests for IdentityRegistry", () => {
await registry.devAddDscKeyCommitment(dscCommitment);
await expect(registry.connect(user1).devRemoveDscKeyCommitment(dscCommitment, [])).to.be.revertedWithCustomError(
registry,
"OwnableUnauthorizedAccount",
"AccessControlUnauthorizedAccount",
);
});
@@ -751,7 +751,7 @@ describe("Unit Tests for IdentityRegistry", () => {
const nullifier = generateRandomFieldElement();
await expect(
registry.connect(user1).devChangeNullifierState(attestationId, nullifier, false),
).to.be.revertedWithCustomError(registry, "OwnableUnauthorizedAccount");
).to.be.revertedWithCustomError(registry, "AccessControlUnauthorizedAccount");
});
it("should not change nullifier state if caller is not proxy", async () => {
@@ -789,7 +789,7 @@ describe("Unit Tests for IdentityRegistry", () => {
const state = true;
await expect(
registry.connect(user1).devChangeDscKeyCommitmentState(dscCommitment, state),
).to.be.revertedWithCustomError(registry, "OwnableUnauthorizedAccount");
).to.be.revertedWithCustomError(registry, "AccessControlUnauthorizedAccount");
});
it("should not change dsc key commitment state if caller is not proxy", async () => {
@@ -915,7 +915,7 @@ describe("Unit Tests for IdentityRegistry", () => {
await expect(
registry.connect(user1).upgradeToAndCall(registryV2Implementation.target, "0x"),
).to.be.revertedWithCustomError(registry, "OwnableUnauthorizedAccount");
).to.be.revertedWithCustomError(registry, "AccessControlUnauthorizedAccount");
});
it("should not allow implementation contract to be initialized directly", async () => {

View File

@@ -1,95 +1,272 @@
import { expect } from "chai";
import { ethers } from "hardhat";
import { ZeroAddress } from "ethers";
import { MockImplRoot } from "../../typechain-types";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
describe("ImplRoot", () => {
let mockImplRoot: MockImplRoot;
let owner: any;
let user1: any;
let deployer: SignerWithAddress;
let securityMultisig: SignerWithAddress;
let operationsMultisig: SignerWithAddress;
let user1: SignerWithAddress;
const SECURITY_ROLE = ethers.keccak256(ethers.toUtf8Bytes("SECURITY_ROLE"));
const OPERATIONS_ROLE = ethers.keccak256(ethers.toUtf8Bytes("OPERATIONS_ROLE"));
beforeEach(async () => {
[owner, user1] = await ethers.getSigners();
[deployer, securityMultisig, operationsMultisig, user1] = await ethers.getSigners();
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot", owner);
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot", deployer);
mockImplRoot = await MockImplRootFactory.deploy();
await mockImplRoot.waitForDeployment();
});
describe("Role Constants", () => {
it("should have correct role constants", async () => {
expect(await mockImplRoot.SECURITY_ROLE()).to.equal(SECURITY_ROLE);
expect(await mockImplRoot.OPERATIONS_ROLE()).to.equal(OPERATIONS_ROLE);
});
});
describe("Initialization", () => {
it("should revert when calling __ImplRoot_init outside initialization phase", async () => {
// First initialize the contract properly
await mockImplRoot.exposed__ImplRoot_init();
// Then try to initialize again - this should fail
await expect(mockImplRoot.exposed__ImplRoot_init()).to.be.revertedWithCustomError(
mockImplRoot,
"NotInitializing",
);
});
it("should revert when initializing with zero address owner", async () => {
await expect(mockImplRoot.exposed__Ownable_init(ZeroAddress))
.to.be.revertedWithCustomError(mockImplRoot, "OwnableInvalidOwner")
.withArgs(ZeroAddress);
});
it("should set correct owner when initializing with valid address", async () => {
await mockImplRoot.exposed__Ownable_init(owner.address);
expect(await mockImplRoot.owner()).to.equal(owner.address);
});
it("should revert when initializing twice", async () => {
await mockImplRoot.exposed__Ownable_init(owner.address);
await expect(mockImplRoot.exposed__Ownable_init(owner.address)).to.be.revertedWithCustomError(
mockImplRoot,
"InvalidInitialization",
);
});
it("should initialize with deployer having both roles", async () => {
// Deploy a fresh contract for initialization testing
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
const freshContract = await MockImplRootFactory.deploy();
await freshContract.waitForDeployment();
await freshContract.exposed__ImplRoot_init();
// Check role assignments - deployer should have both roles
expect(await freshContract.hasRole(SECURITY_ROLE, deployer.address)).to.be.true;
expect(await freshContract.hasRole(OPERATIONS_ROLE, deployer.address)).to.be.true;
// Check role admins - SECURITY_ROLE manages all roles
expect(await freshContract.getRoleAdmin(SECURITY_ROLE)).to.equal(SECURITY_ROLE);
expect(await freshContract.getRoleAdmin(OPERATIONS_ROLE)).to.equal(SECURITY_ROLE);
});
it("should allow role transfer after initialization", async () => {
// Deploy a fresh contract for initialization testing
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
const freshContract = await MockImplRootFactory.deploy();
await freshContract.waitForDeployment();
await freshContract.exposed__ImplRoot_init();
// Transfer roles to multisigs
await freshContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await freshContract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
// Verify multisigs have roles
expect(await freshContract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
expect(await freshContract.hasRole(OPERATIONS_ROLE, operationsMultisig.address)).to.be.true;
// Deployer can renounce roles
await freshContract.connect(deployer).renounceRole(SECURITY_ROLE, deployer.address);
await freshContract.connect(deployer).renounceRole(OPERATIONS_ROLE, deployer.address);
// Verify deployer no longer has roles
expect(await freshContract.hasRole(SECURITY_ROLE, deployer.address)).to.be.false;
expect(await freshContract.hasRole(OPERATIONS_ROLE, deployer.address)).to.be.false;
});
});
describe("Role Management", () => {
let initializedContract: MockImplRoot;
beforeEach(async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
initializedContract = await MockImplRootFactory.deploy();
await initializedContract.waitForDeployment();
// Initialize with deployer having roles, then transfer to multisigs
await initializedContract.exposed__ImplRoot_init();
await initializedContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await initializedContract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
});
it("should allow critical multisig to grant roles", async () => {
await expect(initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
});
it("should allow critical multisig to revoke roles", async () => {
// First grant a role to user1
await initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address);
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
// Then revoke it
await expect(initializedContract.connect(securityMultisig).revokeRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.false;
});
it("should prevent standard multisig from granting critical role", async () => {
await expect(
initializedContract.connect(operationsMultisig).grantRole(SECURITY_ROLE, user1.address),
).to.be.revertedWithCustomError(initializedContract, "AccessControlUnauthorizedAccount");
});
it("should prevent unauthorized users from granting roles", async () => {
await expect(
initializedContract.connect(user1).grantRole(OPERATIONS_ROLE, user1.address),
).to.be.revertedWithCustomError(initializedContract, "AccessControlUnauthorizedAccount");
});
it("should allow role holders to renounce their own roles", async () => {
// Grant role to user1
await initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address);
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
// User1 can renounce their own role
await expect(initializedContract.connect(user1).renounceRole(OPERATIONS_ROLE, user1.address)).to.not.be.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.false;
});
it("should prevent users from renouncing others' roles", async () => {
await expect(
initializedContract.connect(user1).renounceRole(SECURITY_ROLE, securityMultisig.address),
).to.be.revertedWithCustomError(initializedContract, "AccessControlBadConfirmation");
});
});
describe("Upgrade Authorization", () => {
let proxy: any;
let implContract: any;
let initializedContract: MockImplRoot;
beforeEach(async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot", owner);
implContract = await MockImplRootFactory.deploy();
await implContract.waitForDeployment();
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
initializedContract = await MockImplRootFactory.deploy();
await initializedContract.waitForDeployment();
const initData = implContract.interface.encodeFunctionData("exposed__Ownable_init", [owner.address]);
const ProxyFactory = await ethers.getContractFactory("ERC1967Proxy");
proxy = await ProxyFactory.deploy(implContract.target, initData);
await proxy.waitForDeployment();
mockImplRoot = await ethers.getContractAt("MockImplRoot", proxy.target);
// Initialize and transfer roles
await initializedContract.exposed__ImplRoot_init();
await initializedContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await initializedContract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
});
it("should revert when calling _authorizeUpgrade from non-proxy", async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot", owner);
const newImpl = await MockImplRootFactory.deploy();
await newImpl.waitForDeployment();
it("should allow critical multisig to authorize upgrades", async () => {
const newImplementation = ethers.Wallet.createRandom().address;
await expect(implContract.exposed_authorizeUpgrade(newImpl.target)).to.be.revertedWithCustomError(
implContract,
"UUPSUnauthorizedCallContext",
// Note: _authorizeUpgrade is internal and can only be called through proxy upgrade mechanism
// We test this by verifying the critical multisig has the required role
expect(await initializedContract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
});
it("should prevent standard multisig from authorizing upgrades", async () => {
const newImplementation = ethers.Wallet.createRandom().address;
// Standard multisig should not have SECURITY_ROLE
expect(await initializedContract.hasRole(SECURITY_ROLE, operationsMultisig.address)).to.be.false;
});
it("should prevent unauthorized users from authorizing upgrades", async () => {
const newImplementation = ethers.Wallet.createRandom().address;
// Unauthorized users should not have SECURITY_ROLE
expect(await initializedContract.hasRole(SECURITY_ROLE, user1.address)).to.be.false;
});
});
describe("Role Hierarchy", () => {
let initializedContract: MockImplRoot;
beforeEach(async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
initializedContract = await MockImplRootFactory.deploy();
await initializedContract.waitForDeployment();
await initializedContract.exposed__ImplRoot_init();
});
it("should have SECURITY_ROLE as admin of both roles", async () => {
expect(await initializedContract.getRoleAdmin(SECURITY_ROLE)).to.equal(SECURITY_ROLE);
expect(await initializedContract.getRoleAdmin(OPERATIONS_ROLE)).to.equal(SECURITY_ROLE);
});
it("should allow SECURITY_ROLE holders to manage OPERATIONS_ROLE", async () => {
// Grant SECURITY_ROLE to securityMultisig
await initializedContract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
// Critical multisig should be able to grant OPERATIONS_ROLE
await expect(initializedContract.connect(securityMultisig).grantRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.true;
// Critical multisig should be able to revoke OPERATIONS_ROLE
await expect(initializedContract.connect(securityMultisig).revokeRole(OPERATIONS_ROLE, user1.address)).to.not.be
.reverted;
expect(await initializedContract.hasRole(OPERATIONS_ROLE, user1.address)).to.be.false;
});
});
describe("Complete Workflow", () => {
it("should demonstrate complete deployment and role transfer workflow", async () => {
console.log("\n🔄 Starting Complete ImplRoot Workflow");
// 1. Deploy contract
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot");
const contract = await MockImplRootFactory.deploy();
await contract.waitForDeployment();
console.log("✅ Step 1: Contract deployed");
// 2. Initialize with deployer having roles
await contract.exposed__ImplRoot_init();
console.log("✅ Step 2: Contract initialized");
console.log(` - Deployer has SECURITY_ROLE: ${await contract.hasRole(SECURITY_ROLE, deployer.address)}`);
console.log(` - Deployer has OPERATIONS_ROLE: ${await contract.hasRole(OPERATIONS_ROLE, deployer.address)}`);
// 3. Grant roles to multisigs
await contract.connect(deployer).grantRole(SECURITY_ROLE, securityMultisig.address);
await contract.connect(deployer).grantRole(OPERATIONS_ROLE, operationsMultisig.address);
console.log("✅ Step 3: Roles granted to multisigs");
console.log(
` - Critical multisig has SECURITY_ROLE: ${await contract.hasRole(SECURITY_ROLE, securityMultisig.address)}`,
);
console.log(
` - Standard multisig has OPERATIONS_ROLE: ${await contract.hasRole(OPERATIONS_ROLE, operationsMultisig.address)}`,
);
});
it("should revert when non-owner calls _authorizeUpgrade", async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot", owner);
const newImpl = await MockImplRootFactory.deploy();
await newImpl.waitForDeployment();
// 4. Verify multisigs can operate (check role permissions)
expect(await contract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
await expect(mockImplRoot.connect(user1).exposed_authorizeUpgrade(newImpl.target))
.to.be.revertedWithCustomError(mockImplRoot, "OwnableUnauthorizedAccount")
.withArgs(user1.address);
});
console.log("✅ Step 4: Multisigs verified functional");
it("should allow owner to call _authorizeUpgrade through proxy", async () => {
const MockImplRootFactory = await ethers.getContractFactory("MockImplRoot", owner);
const newImpl = await MockImplRootFactory.deploy();
await newImpl.waitForDeployment();
// 5. Renounce deployer roles
await contract.connect(deployer).renounceRole(SECURITY_ROLE, deployer.address);
await contract.connect(deployer).renounceRole(OPERATIONS_ROLE, deployer.address);
await expect(mockImplRoot.connect(owner).exposed_authorizeUpgrade(newImpl.target)).to.not.be.reverted;
console.log("✅ Step 5: Deployer roles renounced");
console.log(` - Deployer has SECURITY_ROLE: ${await contract.hasRole(SECURITY_ROLE, deployer.address)}`);
console.log(` - Deployer has OPERATIONS_ROLE: ${await contract.hasRole(OPERATIONS_ROLE, deployer.address)}`);
// 6. Final verification
expect(await contract.hasRole(SECURITY_ROLE, securityMultisig.address)).to.be.true;
expect(await contract.hasRole(OPERATIONS_ROLE, operationsMultisig.address)).to.be.true;
expect(await contract.hasRole(SECURITY_ROLE, deployer.address)).to.be.false;
expect(await contract.hasRole(OPERATIONS_ROLE, deployer.address)).to.be.false;
console.log("🎉 Complete ImplRoot workflow successful!");
});
});
});

View File

@@ -8,9 +8,14 @@ describe("PCR0Manager", function () {
let owner: SignerWithAddress;
let other: SignerWithAddress;
// Sample PCR0 value for testing (48 bytes)
const samplePCR0 = "0x" + "00".repeat(48);
const invalidPCR0 = "0x" + "00".repeat(32); // 32 bytes (invalid size)
// Sample PCR0 value for testing
// addPCR0/removePCR0 expect 32 bytes (GCP image hash)
const samplePCR0_32bytes = "0x" + "ab".repeat(32);
// isPCR0Set expects 48 bytes (16 zero bytes prefix + 32 byte hash, for mobile compatibility)
const samplePCR0_48bytes = "0x" + "00".repeat(16) + "ab".repeat(32);
// Invalid sizes for testing error cases
const invalidPCR0_for_add = "0x" + "00".repeat(48); // 48 bytes - invalid for add/remove
const invalidPCR0_for_check = "0x" + "00".repeat(32); // 32 bytes - invalid for isPCR0Set
beforeEach(async function () {
[owner, other] = await ethers.getSigners();
@@ -21,74 +26,68 @@ describe("PCR0Manager", function () {
describe("addPCR0", function () {
it("should allow owner to add PCR0 value", async function () {
await expect(pcr0Manager.addPCR0(samplePCR0)).to.emit(pcr0Manager, "PCR0Added");
await expect(pcr0Manager.addPCR0(samplePCR0_32bytes)).to.emit(pcr0Manager, "PCR0Added");
expect(await pcr0Manager.isPCR0Set(samplePCR0)).to.be.true;
});
it("should allow owner to add PCR0 value", async function () {
await expect(pcr0Manager.addPCR0(samplePCR0)).to.emit(pcr0Manager, "PCR0Added");
expect(await pcr0Manager.isPCR0Set(samplePCR0)).to.be.true;
expect(await pcr0Manager.isPCR0Set(samplePCR0_48bytes)).to.be.true;
});
it("should not allow non-owner to add PCR0 value", async function () {
await expect(pcr0Manager.connect(other).addPCR0(samplePCR0))
.to.be.revertedWithCustomError(pcr0Manager, "OwnableUnauthorizedAccount")
.withArgs(other.address);
await expect(pcr0Manager.connect(other).addPCR0(samplePCR0_32bytes))
.to.be.revertedWithCustomError(pcr0Manager, "AccessControlUnauthorizedAccount")
.withArgs(other.address, await pcr0Manager.SECURITY_ROLE());
});
it("should not allow adding PCR0 with invalid size", async function () {
await expect(pcr0Manager.addPCR0(invalidPCR0)).to.be.revertedWith("PCR0 must be 48 bytes");
await expect(pcr0Manager.addPCR0(invalidPCR0_for_add)).to.be.revertedWith("PCR0 must be 32 bytes");
});
it("should not allow adding duplicate PCR0", async function () {
await pcr0Manager.addPCR0(samplePCR0);
await expect(pcr0Manager.addPCR0(samplePCR0)).to.be.revertedWith("PCR0 already set");
await pcr0Manager.addPCR0(samplePCR0_32bytes);
await expect(pcr0Manager.addPCR0(samplePCR0_32bytes)).to.be.revertedWith("PCR0 already set");
});
});
describe("removePCR0", function () {
beforeEach(async function () {
await pcr0Manager.addPCR0(samplePCR0);
await pcr0Manager.addPCR0(samplePCR0_32bytes);
});
it("should allow owner to remove PCR0 value", async function () {
await expect(pcr0Manager.removePCR0(samplePCR0)).to.emit(pcr0Manager, "PCR0Removed");
await expect(pcr0Manager.removePCR0(samplePCR0_32bytes)).to.emit(pcr0Manager, "PCR0Removed");
expect(await pcr0Manager.isPCR0Set(samplePCR0)).to.be.false;
expect(await pcr0Manager.isPCR0Set(samplePCR0_48bytes)).to.be.false;
});
// This is not actually needed, just for increase the coverage of the test code
it("should not allow remove PCR0 with invalid size", async function () {
await expect(pcr0Manager.removePCR0(invalidPCR0)).to.be.revertedWith("PCR0 must be 48 bytes");
await expect(pcr0Manager.removePCR0(invalidPCR0_for_add)).to.be.revertedWith("PCR0 must be 32 bytes");
});
it("should not allow non-owner to remove PCR0 value", async function () {
await expect(pcr0Manager.connect(other).removePCR0(samplePCR0))
.to.be.revertedWithCustomError(pcr0Manager, "OwnableUnauthorizedAccount")
.withArgs(other.address);
await expect(pcr0Manager.connect(other).removePCR0(samplePCR0_32bytes))
.to.be.revertedWithCustomError(pcr0Manager, "AccessControlUnauthorizedAccount")
.withArgs(other.address, await pcr0Manager.SECURITY_ROLE());
});
it("should not allow removing non-existent PCR0", async function () {
const otherPCR0 = "0x" + "11".repeat(48);
const otherPCR0 = "0x" + "11".repeat(32);
await expect(pcr0Manager.removePCR0(otherPCR0)).to.be.revertedWith("PCR0 not set");
});
});
describe("isPCR0Set", function () {
it("should correctly return PCR0 status", async function () {
expect(await pcr0Manager.isPCR0Set(samplePCR0)).to.be.false;
expect(await pcr0Manager.isPCR0Set(samplePCR0_48bytes)).to.be.false;
await pcr0Manager.addPCR0(samplePCR0);
expect(await pcr0Manager.isPCR0Set(samplePCR0)).to.be.true;
await pcr0Manager.addPCR0(samplePCR0_32bytes);
expect(await pcr0Manager.isPCR0Set(samplePCR0_48bytes)).to.be.true;
await pcr0Manager.removePCR0(samplePCR0);
expect(await pcr0Manager.isPCR0Set(samplePCR0)).to.be.false;
await pcr0Manager.removePCR0(samplePCR0_32bytes);
expect(await pcr0Manager.isPCR0Set(samplePCR0_48bytes)).to.be.false;
});
it("should not allow checking PCR0 with invalid size", async function () {
await expect(pcr0Manager.isPCR0Set(invalidPCR0)).to.be.revertedWith("PCR0 must be 48 bytes");
await expect(pcr0Manager.isPCR0Set(invalidPCR0_for_check)).to.be.revertedWith("PCR0 must be 48 bytes");
});
});
});

View File

@@ -149,9 +149,11 @@ describe("Aadhaar Registration test", function () {
});
it("should not fail if timestamp is within 20 minutes", async () => {
// Fix the AADHAAR_REGISTRATION_WINDOW that was incorrectly set to 0
await deployedActors.hub.setAadhaarRegistrationWindow(20);
const latestBlock = await ethers.provider.getBlock("latest");
const blockTimestamp = latestBlock!.timestamp;
const newAadhaarData = prepareAadhaarRegisterTestData(
privateKeyPem,
pubkeyPem,
@@ -161,8 +163,7 @@ describe("Aadhaar Registration test", function () {
"M",
"110051",
"WB",
//timestamp 10 minutes ago and converted to timestamp string
new Date(Date.now() - 10 * 60 * 1000).getTime().toString(),
(blockTimestamp - 10 * 60).toString(),
);
const newRegisterProof = await generateRegisterAadhaarProof(registerSecret, newAadhaarData.inputs);
@@ -171,9 +172,11 @@ describe("Aadhaar Registration test", function () {
});
it("should fail with InvalidUidaiTimestamp when UIDAI timestamp is not within 20 minutes of current time", async () => {
// Fix the AADHAAR_REGISTRATION_WINDOW that was incorrectly set to 0
await deployedActors.hub.setAadhaarRegistrationWindow(20);
const latestBlock = await ethers.provider.getBlock("latest");
const blockTimestamp = latestBlock!.timestamp;
const newAadhaarData = prepareAadhaarRegisterTestData(
privateKeyPem,
pubkeyPem,
@@ -183,8 +186,7 @@ describe("Aadhaar Registration test", function () {
"M",
"110051",
"WB",
//timestamp 30 minutes ago and converted to timestamp string
new Date(Date.now() - 30 * 60 * 1000).getTime().toString(),
(blockTimestamp - 30 * 60).toString(),
);
const newRegisterProof = await generateRegisterAadhaarProof(registerSecret, newAadhaarData.inputs);

View File

@@ -37,7 +37,6 @@
"@babel/core": "^7.28.4",
"@babel/runtime": "^7.28.4",
"@noble/curves": "1.9.7",
"@noble/hashes": "1.8.0",
"@swc/core": "1.7.36",
"@tamagui/animations-react-native": "1.126.14",
"@tamagui/toast": "1.126.14",

View File

@@ -151,7 +151,7 @@
"dependencies": {
"@babel/runtime": "^7.28.3",
"@selfxyz/common": "workspace:^",
"@selfxyz/euclid": "^0.4.1",
"@selfxyz/euclid": "^0.6.0",
"@xstate/react": "^5.0.5",
"node-forge": "^1.3.1",
"react-native-nfc-manager": "^3.17.1",

View File

@@ -18,6 +18,7 @@ const BRANCH = 'main';
// Environment detection
const isCI = process.env.CI === 'true';
const repoToken = process.env.SELFXYZ_INTERNAL_REPO_PAT;
const appToken = process.env.SELFXYZ_APP_TOKEN; // GitHub App installation token
const isDryRun = process.env.DRY_RUN === 'true';
function log(message, type = 'info') {
@@ -89,19 +90,24 @@ function setupSubmodule() {
let submoduleUrl;
if (isCI && repoToken) {
if (isCI && appToken) {
// CI environment with GitHub App installation token
// Security: NEVER embed credentials in git URLs. Rely on CI-provided auth via:
// - ~/.netrc, a Git credential helper, or SSH agent configuration.
submoduleUrl = `https://github.com/${GITHUB_ORG}/${REPO_NAME}.git`;
} else if (isCI && repoToken) {
// CI environment with Personal Access Token
log('CI detected: Using SELFXYZ_INTERNAL_REPO_PAT for submodule', 'info');
submoduleUrl = `https://${repoToken}@github.com/${GITHUB_ORG}/${REPO_NAME}.git`;
// Security: NEVER embed credentials in git URLs. Rely on CI-provided auth via:
// - ~/.netrc, a Git credential helper, or SSH agent configuration.
submoduleUrl = `https://github.com/${GITHUB_ORG}/${REPO_NAME}.git`;
} else if (isCI) {
log('CI environment detected but SELFXYZ_INTERNAL_REPO_PAT not available - skipping private module setup', 'info');
log('CI environment detected but no token available - skipping private module setup', 'info');
log('This is expected for forked PRs or environments without access to private modules', 'info');
return false; // Return false to indicate setup was skipped
} else if (usingHTTPSGitAuth()) {
submoduleUrl = `https://github.com/${GITHUB_ORG}/${REPO_NAME}.git`;
} else {
// Local development with SSH
log('Local development: Using SSH for submodule', 'info');
submoduleUrl = `git@github.com:${GITHUB_ORG}/${REPO_NAME}.git`;
}
@@ -113,7 +119,7 @@ function setupSubmodule() {
} else {
// Add submodule
const addCommand = `git submodule add -b ${BRANCH} "${submoduleUrl}" mobile-sdk-native`;
if (isCI && repoToken) {
if (isCI && (appToken || repoToken)) {
// Security: Run command silently to avoid token exposure in logs
runCommand(addCommand, { stdio: 'pipe' });
} else {
@@ -125,7 +131,7 @@ function setupSubmodule() {
return true; // Return true to indicate successful setup
} catch (error) {
if (isCI) {
log('Submodule setup failed in CI environment. Check SELFXYZ_INTERNAL_REPO_PAT permissions.', 'error');
log('Submodule setup failed in CI environment. Check repository access/credentials configuration.', 'error');
} else {
log('Submodule setup failed. Ensure you have SSH access to the repository.', 'error');
}
@@ -169,7 +175,7 @@ function setupMobileSDKNative() {
}
// Security: Remove credential-embedded remote URL after setup
if (isCI && repoToken && !isDryRun) {
if (isCI && (appToken || repoToken) && !isDryRun) {
scrubGitRemoteUrl();
}

View File

@@ -79,6 +79,7 @@ export const BackupEvents = {
};
export const DocumentEvents = {
COUNTRY_HELP_TAPPED: 'Document: Country Help Tapped',
ADD_NEW_AADHAAR_SELECTED: 'Document: Add Aadhaar',
ADD_NEW_MOCK_SELECTED: 'Document: Add New Document via Mock',
ADD_NEW_SCAN_SELECTED: 'Document: Add New Document via Scan',

View File

@@ -5,15 +5,18 @@
import { useCallback, useState } from 'react';
import { commonNames } from '@selfxyz/common/constants/countries';
import { CountryPickerScreen as CountryPickerUI } from '@selfxyz/euclid';
import { CountryPickerScreen as CountryPickerUI, type SafeArea } from '@selfxyz/euclid';
import { RoundFlag } from '../../components';
import { DocumentEvents } from '../../constants/analytics';
import { useSelfClient } from '../../context';
import { useCountries } from '../../documents/useCountries';
import { buttonTap } from '../../haptic';
import { SdkEvents } from '../../types/events';
const CountryPickerScreen: React.FC = () => {
const CountryPickerScreen: React.FC<SafeArea> & { statusBar: typeof CountryPickerUI.statusBar } = ({
insets,
}: SafeArea) => {
const selfClient = useSelfClient();
const [searchValue, setSearchValue] = useState('');
@@ -57,9 +60,9 @@ const CountryPickerScreen: React.FC = () => {
const onSearchChange = useCallback((value: string) => {
setSearchValue(value);
}, []);
return (
<CountryPickerUI
insets={insets}
isLoading={loading}
countries={countryList}
onCountrySelect={onCountrySelect}
@@ -69,11 +72,11 @@ const CountryPickerScreen: React.FC = () => {
getCountryName={getCountryName}
searchValue={searchValue}
onClose={selfClient.goBack}
onInfoPress={() => console.log('Info pressed TODO: Implement')}
onInfoPress={() => selfClient.trackEvent(DocumentEvents.COUNTRY_HELP_TAPPED)}
onSearchChange={onSearchChange}
/>
);
};
CountryPickerScreen.displayName = 'CountryPickerScreen';
CountryPickerScreen.statusBar = CountryPickerUI.statusBar;
export default CountryPickerScreen;

View File

@@ -46,12 +46,15 @@ target "SelfDemoApp" do
nfc_repo_url = if !is_selfxyz_repo
puts "📦 Using public NFCPassportReader for external fork (#{ENV["GITHUB_REPOSITORY"]})"
"https://github.com/PLACEHOLDER/NFCPassportReader.git"
elsif ENV["GITHUB_ACTIONS"] == "true" && ENV["SELFXYZ_INTERNAL_REPO_PAT"] && !ENV["SELFXYZ_INTERNAL_REPO_PAT"].empty?
puts "📦 Using private NFCPassportReader with PAT (selfxyz GitHub Actions)"
"https://#{ENV["SELFXYZ_INTERNAL_REPO_PAT"]}@github.com/selfxyz/NFCPassportReader.git"
elsif ENV["GITHUB_ACTIONS"] == "true"
# CI: NEVER embed credentials in URLs. Rely on workflow-provided auth via:
# - ~/.netrc or a Git credential helper, and token masking in logs.
"https://github.com/selfxyz/NFCPassportReader.git"
elsif using_https_git_auth?
# Local development with HTTPS GitHub auth via gh - use HTTPS to private repo
"https://github.com/selfxyz/NFCPassportReader.git"
else
# Local development in selfxyz repo - use SSH to private repo
puts "📦 Using SSH for private NFCPassportReader (local selfxyz development)"
"git@github.com:selfxyz/NFCPassportReader.git"
end

View File

@@ -5,7 +5,7 @@ import ScreenLayout from '../components/ScreenLayout';
export default function CountrySelection({ onBack }: { onBack: () => void }) {
return (
<ScreenLayout title="GETTING STARTED" onBack={onBack}>
<SDKCountryPickerScreen />
<SDKCountryPickerScreen insets={{ top: 0, bottom: 0 }} />
</ScreenLayout>
);
}

1532
yarn.lock

File diff suppressed because it is too large Load Diff