diff --git a/src/common/dripsContracts.ts b/src/common/dripsContracts.ts index 05a38c3..975429b 100644 --- a/src/common/dripsContracts.ts +++ b/src/common/dripsContracts.ts @@ -251,3 +251,51 @@ export async function getCrossChainOrcidAccountIdByOrcidId( return accountId as RepoDriverId; } + +/** + * Returns all possible ORCID account IDs for a given ORCID iD. + * + * This is needed for backwards compatibility: legacy ORCID sandbox accounts use + * sourceId=2 with "sandbox-" prefixed names, while Lit-claimed sandbox accounts + * use sourceId=4 with plain ORCID iDs. This function computes both possible + * account IDs so the resolver can try each one. + */ +export async function getAllPossibleOrcidAccountIds( + orcidId: string, + chainsToQuery: DbSchema[], +): Promise { + const availableChain = chainsToQuery.find( + (chain) => + dripsContracts[dbSchemaToChain[chain]] && + dripsContracts[dbSchemaToChain[chain]]!.repoDriver, + ); + + if (!availableChain) { + throw new Error('No available chain with initialized contracts.'); + } + + const { repoDriver } = dripsContracts[dbSchemaToChain[availableChain]]!; + + const accountIds: RepoDriverId[] = []; + + // Legacy account ID: sourceId=2, with the orcid string as provided + // (may include "sandbox-" prefix for legacy sandbox accounts) + const legacyAccountId = ( + await repoDriver.calcAccountId(2, ethers.toUtf8Bytes(orcidId)) + ).toString() as RepoDriverId; + accountIds.push(legacyAccountId); + + // Lit sandbox account ID: sourceId=4, with plain ORCID (no "sandbox-" prefix) + const plainOrcid = orcidId.startsWith('sandbox-') + ? orcidId.slice('sandbox-'.length) + : orcidId; + const litSandboxAccountId = ( + await repoDriver.calcAccountId(4, ethers.toUtf8Bytes(plainOrcid)) + ).toString() as RepoDriverId; + + if (litSandboxAccountId !== legacyAccountId) { + accountIds.push(litSandboxAccountId); + } + + return accountIds; +} diff --git a/src/linked-identity/linkedIdentityResolvers.ts b/src/linked-identity/linkedIdentityResolvers.ts index 1a29b95..8e9877e 100644 --- a/src/linked-identity/linkedIdentityResolvers.ts +++ b/src/linked-identity/linkedIdentityResolvers.ts @@ -23,11 +23,10 @@ import { } from '../utils/assert'; import validateOrcidExists from '../orcid-account/validateOrcidExists'; import fetchOrcidProfile from '../orcid-account/orcidApi'; -import { getCrossChainOrcidAccountIdByOrcidId } from '../common/dripsContracts'; +import { getAllPossibleOrcidAccountIds } from '../common/dripsContracts'; import { extractOrcidFromAccountId } from '../orcid-account/orcidAccountIdUtils'; import validateLinkedIdentitiesInput from './linkedIdentityValidators'; import { validateChainsQueryArg } from '../utils/commonInputValidators'; -import type { RepoDriverId } from '../common/types'; import { resolveTotalEarned } from '../common/commonResolverLogic'; import getWithdrawableBalancesOnChain from '../utils/getWithdrawableBalances'; import { PUBLIC_ERROR_CODES } from '../utils/formatError'; @@ -101,21 +100,29 @@ const linkedIdentityResolvers = { const exists = await validateOrcidExists(orcid); if (!exists) return null; - // Try to find the account with the ORCID as provided (which may include sandbox- prefix) - const orcidAccountId: RepoDriverId = - await getCrossChainOrcidAccountIdByOrcidId(orcid, [ - chainToDbSchema[chain], - ]); + // Compute all possible account IDs for this ORCID. Legacy sandbox accounts + // use sourceId=2 with "sandbox-" prefixed names, while Lit-claimed sandbox + // accounts use sourceId=4 with plain ORCID iDs. + const candidateAccountIds = await getAllPossibleOrcidAccountIds(orcid, [ + chainToDbSchema[chain], + ]); + + // Try each candidate account ID, return the first match found + for (const accountId of candidateAccountIds) { + assertIsLinkedIdentityId(accountId); + const identity = await linkedIdentitiesDataSource.getLinkedIdentityById( + accountId, + [chainToDbSchema[chain]], + ); - assertIsLinkedIdentityId(orcidAccountId); - const identity = await linkedIdentitiesDataSource.getLinkedIdentityById( - orcidAccountId, - [chainToDbSchema[chain]], - ); + if (identity) { + return toGqlLinkedIdentity(identity); + } + } - return identity - ? toGqlLinkedIdentity(identity) - : toFakeUnclaimedOrcid(orcid, orcidAccountId, chain); + // No identity found — return a fake unclaimed entry using the first candidate + assertIsLinkedIdentityId(candidateAccountIds[0]); + return toFakeUnclaimedOrcid(orcid, candidateAccountIds[0], chain); }, }, OrcidLinkedIdentity: { diff --git a/src/orcid-account/orcidAccountIdUtils.ts b/src/orcid-account/orcidAccountIdUtils.ts index 7f6930e..ab06cf5 100644 --- a/src/orcid-account/orcidAccountIdUtils.ts +++ b/src/orcid-account/orcidAccountIdUtils.ts @@ -3,38 +3,37 @@ import { isRepoDriverId } from '../utils/assert'; /** - * ForgeId for ORCID in RepoDriver account IDs. + * Source IDs for ORCID in the contract. + * - 2: regular ORCID + * - 4: sandbox ORCID (Lit oracle uses a separate sourceId instead of a name prefix) + */ +export const ORCID_SOURCE_IDS = [2, 4]; + +/** + * Extracts the sourceId from a RepoDriver account ID. * - * Value is 4 (not 2 like the Forge.ORCID enum) because the forgeId field encodes - * both forge type and name length constraints: - * - 0,1: GitHub (supports different name lengths) - * - 2,3: GitLab (supports different name lengths) - * - 4: ORCID (fixed format: XXXX-XXXX-XXXX-XXXX) + * Account ID layout: `driverId (32 bits) | sourceId (7 bits) | isHash (1 bit) | nameEncoded (216 bits)` * - * This allows the account ID bit structure to efficiently pack forge identification - * and validation rules into a single field. + * The 8 bits at positions 216-223 encode `sourceId (7 bits) | isHash (1 bit)`. + * We shift right by 1 to drop the isHash bit and get the pure sourceId. */ -export const ORCID_FORGE_ID = 4; - -function extractForgeFromAccountId(accountId: string): number { +function extractSourceIdFromAccountId(accountId: string): number { if (!isRepoDriverId(accountId)) { throw new Error( - `Cannot extract forge: '${accountId}' is not a RepoDriver ID.`, + `Cannot extract sourceId: '${accountId}' is not a RepoDriver ID.`, ); } const accountIdAsBigInt = BigInt(accountId); - // RepoDriver account ID structure: [32-bit driverId][8-bit forgeId][216-bit nameEncoded] - // Extract forgeId from bits 216-223 (8 bits) by shifting right 216 bits and masking - const forgeId = (accountIdAsBigInt >> 216n) & 0xffn; - return Number(forgeId); + const sourceIdAndHash = (accountIdAsBigInt >> 216n) & 0xffn; + return Number(sourceIdAndHash >> 1n); } export function isOrcidAccount(accountId: string): boolean { try { return ( isRepoDriverId(accountId) && - extractForgeFromAccountId(accountId) === ORCID_FORGE_ID + ORCID_SOURCE_IDS.includes(extractSourceIdFromAccountId(accountId)) ); } catch { return false; diff --git a/tests/linked-identity/linkedIdentityResolvers.test.ts b/tests/linked-identity/linkedIdentityResolvers.test.ts index 3a2c74b..9fb29f9 100644 --- a/tests/linked-identity/linkedIdentityResolvers.test.ts +++ b/tests/linked-identity/linkedIdentityResolvers.test.ts @@ -23,7 +23,7 @@ import validateLinkedIdentitiesInput from '../../src/linked-identity/linkedIdent import { validateChainsQueryArg } from '../../src/utils/commonInputValidators'; import * as assertUtils from '../../src/utils/assert'; import validateOrcidExists from '../../src/orcid-account/validateOrcidExists'; -import { getCrossChainOrcidAccountIdByOrcidId } from '../../src/common/dripsContracts'; +import { getAllPossibleOrcidAccountIds } from '../../src/common/dripsContracts'; vi.mock('../../src/linked-identity/linkedIdentityUtils'); vi.mock('../../src/linked-identity/linkedIdentityValidators'); @@ -183,9 +183,9 @@ describe('linkedIdentityResolvers', () => { vi.mocked(assertUtils.isOrcidId).mockReturnValue(true); vi.mocked(validateOrcidExists).mockResolvedValue(true); - vi.mocked(getCrossChainOrcidAccountIdByOrcidId).mockResolvedValue( + vi.mocked(getAllPossibleOrcidAccountIds).mockResolvedValue([ linkedIdentityId as unknown as RepoDriverId, - ); + ]); vi.mocked(assertUtils.assertIsLinkedIdentityId).mockImplementation( () => {}, // eslint-disable-line no-empty-function ); @@ -211,7 +211,7 @@ describe('linkedIdentityResolvers', () => { expect(validateOrcidExists).toHaveBeenCalledWith(orcid); - expect(getCrossChainOrcidAccountIdByOrcidId).toHaveBeenCalledWith(orcid, [ + expect(getAllPossibleOrcidAccountIds).toHaveBeenCalledWith(orcid, [ 'mainnet', ]); @@ -244,9 +244,9 @@ describe('linkedIdentityResolvers', () => { vi.mocked(assertUtils.isOrcidId).mockReturnValue(true); vi.mocked(validateOrcidExists).mockResolvedValue(true); - vi.mocked(getCrossChainOrcidAccountIdByOrcidId).mockResolvedValue( + vi.mocked(getAllPossibleOrcidAccountIds).mockResolvedValue([ linkedIdentityId as unknown as RepoDriverId, - ); + ]); vi.mocked(assertUtils.assertIsLinkedIdentityId).mockImplementation( () => {}, // eslint-disable-line no-empty-function ); @@ -266,7 +266,7 @@ describe('linkedIdentityResolvers', () => { expect(validateChainsQueryArg).toHaveBeenCalledWith([args.chain]); expect(validateOrcidExists).toHaveBeenCalledWith(orcid); - expect(getCrossChainOrcidAccountIdByOrcidId).toHaveBeenCalledWith(orcid, [ + expect(getAllPossibleOrcidAccountIds).toHaveBeenCalledWith(orcid, [ 'mainnet', ]); expect(assertUtils.assertIsLinkedIdentityId).toHaveBeenCalledWith(