fix: OFAC trees not found (#1060)

* fix: relax OFAC tree response validation

* test: cover OFAC tree edge cases

* fix stateless

* revert and fix types

* fix tests
This commit is contained in:
Justin Hernandez
2025-09-12 14:03:18 -07:00
committed by GitHub
parent 85df67604a
commit 94d8fcada5
6 changed files with 537 additions and 9 deletions

View File

@@ -18,6 +18,7 @@ import {
} from '@selfxyz/common/utils';
import { getPublicKey, verifyAttestation } from '@selfxyz/common/utils/attest';
import {
generateTEEInputsDiscloseStateless,
generateTEEInputsDSC,
generateTEEInputsRegister,
} from '@selfxyz/common/utils/circuits/registerInputs';
@@ -38,7 +39,6 @@ import {
} from '@selfxyz/common/utils/proving';
import {
clearPassportData,
generateTEEInputsDisclose,
hasAnyValidRegisteredDocument,
loadSelectedDocument,
markCurrentDocumentAsRegistered,
@@ -459,7 +459,7 @@ export const useProvingStore = create<ProvingState>((set, get) => {
selfClient.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, {
hasValidDocument: hasValid,
});
} catch (error) {
} catch {
selfClient.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, {
hasValidDocument: false,
});
@@ -1015,7 +1015,7 @@ export const useProvingStore = create<ProvingState>((set, get) => {
}
},
_closeConnections: (selfClient: SelfClient) => {
_closeConnections: (_selfClient: SelfClient) => {
const { wsConnection: ws, wsHandlers } = get();
if (ws && wsHandlers) {
try {
@@ -1088,10 +1088,27 @@ export const useProvingStore = create<ProvingState>((set, get) => {
break;
case 'disclose':
({ inputs, circuitName, endpointType, endpoint } =
generateTEEInputsDisclose(
generateTEEInputsDiscloseStateless(
secret as string,
passportData,
selfApp as SelfApp,
(doc: DocumentCategory, tree) => {
const docStore =
doc === 'passport'
? protocolStore.passport
: protocolStore.id_card;
switch (tree) {
case 'ofac':
return docStore.ofac_trees;
case 'commitment':
if (!docStore.commitment_tree) {
throw new Error('Commitment tree not loaded');
}
return docStore.commitment_tree;
default:
throw new Error('Unknown tree type');
}
},
));
circuitTypeWithDocumentExtension = `disclose`;
break;

View File

@@ -0,0 +1,342 @@
// 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 type { SelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
useProtocolStore,
useSelfAppStore,
} from '@selfxyz/mobile-sdk-alpha/stores';
// Do not import provingMachine here; we'll require it after setting up mocks per test
jest.mock('xstate', () => {
const actual = jest.requireActual('xstate') as any;
const { actorMock } = require('./actorMock');
return { ...actual, createActor: jest.fn(() => actorMock) };
});
// Mock proving utils for payload building
jest.mock('@selfxyz/common/utils/proving', () => {
const actual = jest.requireActual('@selfxyz/common/utils/proving') as any;
return {
...actual,
getPayload: jest.fn(() => ({ mocked: true })),
encryptAES256GCM: jest.fn(() => ({
nonce: [0],
cipher_text: [1],
auth_tag: [2],
})),
};
});
describe('_generatePayload disclose (stateless resolver)', () => {
const selfClient: SelfClient = {
trackEvent: jest.fn(),
} as unknown as SelfClient;
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
useSelfAppStore.setState({
selfApp: {
chainID: 42220,
userId: '12345678-1234-1234-1234-123456789abc',
userDefinedData: '0x0',
endpointType: 'https',
endpoint: 'https://endpoint',
scope: 'scope',
sessionId: '',
appName: '',
logoBase64: '',
header: '',
userIdType: 'uuid',
devMode: false,
disclosures: {},
version: 1,
deeplinkCallback: '',
},
});
});
it('uses resolver to fetch ofac and commitment trees', async () => {
// Mock the stateless generator to assert resolver behavior
const genMock = jest.fn((secret, passportData, selfApp, getTree) => {
const ofac = getTree('passport', 'ofac');
const commit = getTree('passport', 'commitment');
expect(ofac).toEqual({
passportNoAndNationality: { root: ['pp'] },
nameAndDob: { root: ['dob'] },
nameAndYob: { root: ['yob'] },
});
expect(commit).toBe('[[]]');
return {
inputs: { s: 1 },
circuitName: 'vc_and_disclose',
endpointType: 'https',
endpoint: 'https://dis',
};
});
jest.doMock('@selfxyz/common/utils/circuits/registerInputs', () => ({
generateTEEInputsDiscloseStateless: genMock,
generateTEEInputsRegister: jest.fn(),
generateTEEInputsDSC: jest.fn(),
}));
// Act (reload module after doMock)
let store: any;
let protocolStore: any;
jest.isolateModules(() => {
// require after mocks are in place
const mod = require('@/utils/proving/provingMachine');
const {
useProtocolStore: isolatedProtocolStore,
} = require('@selfxyz/mobile-sdk-alpha/stores');
store = mod.useProvingStore;
protocolStore = isolatedProtocolStore;
// Set protocol store state inside isolateModules
protocolStore.setState({
passport: {
dsc_tree: 'tree',
csca_tree: [[new Uint8Array([1])]],
commitment_tree: '[[]]',
deployed_circuits: null,
circuits_dns_mapping: null,
alternative_csca: {},
ofac_trees: {
passportNoAndNationality: { root: ['pp'] },
nameAndDob: { root: ['dob'] },
nameAndYob: { root: ['yob'] },
},
fetch_deployed_circuits: jest.fn(),
fetch_circuits_dns_mapping: jest.fn(),
fetch_csca_tree: jest.fn(),
fetch_dsc_tree: jest.fn(),
fetch_identity_tree: jest.fn(),
fetch_alternative_csca: jest.fn(),
fetch_ofac_trees: jest.fn(),
fetch_all: jest.fn(),
},
id_card: {
commitment_tree: null,
dsc_tree: null,
csca_tree: null,
deployed_circuits: null,
circuits_dns_mapping: null,
alternative_csca: {},
ofac_trees: null,
fetch_deployed_circuits: jest.fn(),
fetch_circuits_dns_mapping: jest.fn(),
fetch_csca_tree: jest.fn(),
fetch_dsc_tree: jest.fn(),
fetch_identity_tree: jest.fn(),
fetch_alternative_csca: jest.fn(),
fetch_ofac_trees: jest.fn(),
fetch_all: jest.fn(),
},
} as any);
// Set proving store state inside isolateModules so it affects the isolated store instance
store.setState({
circuitType: 'disclose',
passportData: {
documentCategory: 'passport',
mock: false,
dsc_parsed: { authorityKeyIdentifier: 'abcd' },
passportMetadata: {
signatureAlgorithm: 'rsa_pss_rsae_sha256',
signedAttrHashFunction: 'sha256',
issuer: 'X',
validFrom: new Date('2020-01-01'),
validTo: new Date('2030-01-01'),
},
mrz: 'P<UTO...MOCKMRZ...',
eContent: [],
signedAttr: [],
encryptedDigest: [],
} as any,
secret: 'sec',
uuid: 'uuid-123',
sharedKey: Buffer.alloc(32, 1),
env: 'prod',
});
});
const payload = await store.getState()._generatePayload(selfClient);
// Assert
expect(genMock).toHaveBeenCalled();
expect(store.getState().endpointType).toBe('https');
expect(payload.params).toEqual({
uuid: 'uuid-123',
nonce: [0],
cipher_text: [1],
auth_tag: [2],
});
});
it('throws when commitment tree is missing', async () => {
const genMock = jest.fn((secret, passportData, selfApp, getTree) => {
// This should throw inside resolver when requesting commitment
getTree('passport', 'commitment');
return {
inputs: {},
circuitName: '',
endpointType: 'https',
endpoint: '',
};
});
jest.doMock('@selfxyz/common/utils/circuits/registerInputs', () => ({
generateTEEInputsDiscloseStateless: genMock,
generateTEEInputsRegister: jest.fn(),
generateTEEInputsDSC: jest.fn(),
}));
let store: any;
let protocolStore: any;
jest.isolateModules(() => {
const mod = require('@/utils/proving/provingMachine');
const {
useProtocolStore: isolatedProtocolStore,
} = require('@selfxyz/mobile-sdk-alpha/stores');
store = mod.useProvingStore;
protocolStore = isolatedProtocolStore;
// Set protocol store state inside isolateModules - missing commitment tree
protocolStore.setState({
passport: {
dsc_tree: 'tree',
csca_tree: [[new Uint8Array([1])]],
commitment_tree: null,
deployed_circuits: null,
circuits_dns_mapping: null,
alternative_csca: {},
ofac_trees: {
passportNoAndNationality: { root: ['pp'] },
nameAndDob: { root: ['dob'] },
nameAndYob: { root: ['yob'] },
},
fetch_deployed_circuits: jest.fn(),
fetch_circuits_dns_mapping: jest.fn(),
fetch_csca_tree: jest.fn(),
fetch_dsc_tree: jest.fn(),
fetch_identity_tree: jest.fn(),
fetch_alternative_csca: jest.fn(),
fetch_ofac_trees: jest.fn(),
fetch_all: jest.fn(),
},
id_card: {} as any,
} as any);
// Set store state inside isolateModules so it affects the isolated store instance
store.setState({
circuitType: 'disclose',
passportData: {
documentCategory: 'passport',
mock: false,
dsc_parsed: { authorityKeyIdentifier: 'abcd' },
passportMetadata: {
signatureAlgorithm: 'rsa_pss_rsae_sha256',
signedAttrHashFunction: 'sha256',
issuer: 'X',
validFrom: new Date('2020-01-01'),
validTo: new Date('2030-01-01'),
},
mrz: 'P<UTO...MOCKMRZ...',
eContent: [],
signedAttr: [],
encryptedDigest: [],
} as any,
secret: 'sec',
uuid: 'uuid-123',
sharedKey: Buffer.alloc(32, 1),
env: 'prod',
});
});
await expect(store.getState()._generatePayload(selfClient)).rejects.toThrow(
'Commitment tree not loaded',
);
});
it('throws when OFAC trees are missing', async () => {
const genMock = jest.fn((secret, passportData, selfApp, getTree) => {
const ofac = getTree('passport', 'ofac');
if (!ofac) {
throw new Error('OFAC trees not loaded');
}
return {
inputs: {},
circuitName: '',
endpointType: 'https',
endpoint: '',
};
});
jest.doMock('@selfxyz/common/utils/circuits/registerInputs', () => ({
generateTEEInputsDiscloseStateless: genMock,
generateTEEInputsRegister: jest.fn(),
generateTEEInputsDSC: jest.fn(),
}));
let store: any;
let protocolStore: any;
jest.isolateModules(() => {
const mod = require('@/utils/proving/provingMachine');
const {
useProtocolStore: isolatedProtocolStore,
} = require('@selfxyz/mobile-sdk-alpha/stores');
store = mod.useProvingStore;
protocolStore = isolatedProtocolStore;
// Set protocol store state inside isolateModules - missing OFAC trees
protocolStore.setState({
passport: {
dsc_tree: 'tree',
csca_tree: [[new Uint8Array([1])]],
commitment_tree: '[[]]',
deployed_circuits: null,
circuits_dns_mapping: null,
alternative_csca: {},
ofac_trees: null,
fetch_deployed_circuits: jest.fn(),
fetch_circuits_dns_mapping: jest.fn(),
fetch_csca_tree: jest.fn(),
fetch_dsc_tree: jest.fn(),
fetch_identity_tree: jest.fn(),
fetch_alternative_csca: jest.fn(),
fetch_ofac_trees: jest.fn(),
fetch_all: jest.fn(),
},
id_card: {} as any,
} as any);
// Set store state inside isolateModules so it affects the isolated store instance
store.setState({
circuitType: 'disclose',
passportData: {
documentCategory: 'passport',
mock: false,
dsc_parsed: { authorityKeyIdentifier: 'abcd' },
passportMetadata: {
signatureAlgorithm: 'rsa_pss_rsae_sha256',
signedAttrHashFunction: 'sha256',
issuer: 'X',
validFrom: new Date('2020-01-01'),
validTo: new Date('2030-01-01'),
},
mrz: 'P<UTO...MOCKMRZ...',
eContent: [],
signedAttr: [],
encryptedDigest: [],
} as any,
secret: 'sec',
uuid: 'uuid-123',
sharedKey: Buffer.alloc(32, 1),
env: 'prod',
});
});
await expect(store.getState()._generatePayload(selfClient)).rejects.toThrow(
'OFAC trees not loaded',
);
});
});

View File

@@ -0,0 +1,73 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchOfacTrees } from './ofac';
const originalFetch = global.fetch;
describe('fetchOfacTrees', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
it('accepts raw tree payloads', async () => {
const responses: Record<string, any> = {
'passport-no-nationality': { root: ['pp'] },
'name-dob': { root: ['dob'] },
'name-yob': { root: ['yob'] },
};
vi.spyOn(global, 'fetch').mockImplementation(
(input: string | Request | URL, _init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const key = url.includes('passport-no-nationality')
? 'passport-no-nationality'
: url.includes('name-dob')
? 'name-dob'
: 'name-yob';
return Promise.resolve({ ok: true, json: async () => responses[key] } as Response);
}
);
const trees = await fetchOfacTrees('prod', 'passport');
expect(trees).toEqual({
passportNoAndNationality: responses['passport-no-nationality'],
nameAndDob: responses['name-dob'],
nameAndYob: responses['name-yob'],
});
});
it('accepts wrapped {status, data} payloads', async () => {
const responses: Record<string, any> = {
'passport-no-nationality': { status: 'success', data: { root: ['pp'] } },
'name-dob': { status: 'success', data: { root: ['dob'] } },
'name-yob': { status: 'success', data: { root: ['yob'] } },
};
vi.spyOn(global, 'fetch').mockImplementation(
(input: string | Request | URL, _init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const key = url.includes('passport-no-nationality')
? 'passport-no-nationality'
: url.includes('name-dob')
? 'name-dob'
: 'name-yob';
return Promise.resolve({ ok: true, json: async () => responses[key] } as Response);
}
);
const trees = await fetchOfacTrees('prod', 'passport');
expect(trees).toEqual({
passportNoAndNationality: responses['passport-no-nationality'].data,
nameAndDob: responses['name-dob'].data,
nameAndYob: responses['name-yob'].data,
});
});
});

View File

@@ -14,12 +14,19 @@ const fetchTree = async (url: string): Promise<any> => {
throw new Error(`HTTP error fetching ${url}! status: ${res.status}`);
}
const responseData = await res.json();
if (responseData.status !== 'success' || !responseData.data) {
throw new Error(
`Failed to fetch tree from ${url}: ${responseData.message || 'Invalid response format'}`
);
// Handle wrapped responses with {status: 'success', data: ...} format
if (responseData && typeof responseData === 'object' && 'status' in responseData) {
if (responseData.status !== 'success' || !responseData.data) {
throw new Error(
`Failed to fetch tree from ${url}: ${responseData.message || 'Invalid response format'}`
);
}
return responseData.data;
}
return responseData.data;
// Handle raw responses (direct tree data)
return responseData;
};
// Main public helper that retrieves the three OFAC trees depending on the variant (passport vs id_card).

View File

@@ -145,4 +145,17 @@ describe('generateTEEInputsDisclose', () => {
`Invalid OFAC tree structure: missing required fields`,
);
});
it('throws error if OFAC trees not loaded', () => {
vi.spyOn(useProtocolStore, 'getState').mockReturnValue({
passport: {
ofac_trees: null,
commitment_tree: '[[]]',
},
} as any);
expect(() => generateTEEInputsDisclose(mockSecret, mockPassportData, mockSelfApp)).toThrowError(
'OFAC trees not loaded',
);
});
});

View File

@@ -0,0 +1,76 @@
// 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.
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useProtocolStore } from '../../src/stores/protocolStore';
const originalFetch = global.fetch;
describe('protocolStore.fetch_ofac_trees', () => {
beforeEach(() => {
useProtocolStore.setState(state => ({
passport: { ...state.passport, ofac_trees: null },
}));
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('stores OFAC trees when responses are raw payloads', async () => {
const responses: Record<string, any> = {
'passport-no-nationality': { root: ['pp'] },
'name-dob': { root: ['dob'] },
'name-yob': { root: ['yob'] },
};
vi.spyOn(global, 'fetch').mockImplementation((input: string | Request | URL, _init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const key = url.includes('passport-no-nationality')
? 'passport-no-nationality'
: url.includes('name-dob')
? 'name-dob'
: 'name-yob';
return Promise.resolve({ ok: true, json: async () => responses[key] } as Response);
});
await useProtocolStore.getState().passport.fetch_ofac_trees('prod');
expect(useProtocolStore.getState().passport.ofac_trees).toEqual({
passportNoAndNationality: responses['passport-no-nationality'],
nameAndDob: responses['name-dob'],
nameAndYob: responses['name-yob'],
});
});
it('stores OFAC trees when responses are wrapped payloads', async () => {
const responses: Record<string, any> = {
'passport-no-nationality': { status: 'success', data: { root: ['pp'] } },
'name-dob': { status: 'success', data: { root: ['dob'] } },
'name-yob': { status: 'success', data: { root: ['yob'] } },
};
vi.spyOn(global, 'fetch').mockImplementation((input: string | Request | URL, _init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const key = url.includes('passport-no-nationality')
? 'passport-no-nationality'
: url.includes('name-dob')
? 'name-dob'
: 'name-yob';
return Promise.resolve({ ok: true, json: async () => responses[key] } as Response);
});
await useProtocolStore.getState().passport.fetch_ofac_trees('prod');
expect(useProtocolStore.getState().passport.ofac_trees).toEqual({
passportNoAndNationality: responses['passport-no-nationality'].data,
nameAndDob: responses['name-dob'].data,
nameAndYob: responses['name-yob'].data,
});
});
});