Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/common/dripsContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RepoDriverId[]> {
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);
Comment on lines +289 to +297
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function getAllPossibleOrcidAccountIds always adds sourceId=4 (Lit sandbox) candidates for production ORCID IDs that don't start with "sandbox-". However, based on the PR description, sourceId=4 is specifically for Lit-claimed sandbox accounts. This means for production ORCID IDs like "0000-0002-1825-0097", the function will compute and return a sourceId=4 account ID that would never be valid. Consider only computing the sourceId=4 account ID when the orcidId starts with "sandbox-" or when it looks like a sandbox ORCID pattern (starts with "0009-").

Suggested change
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);
// Only compute this for sandbox-style ORCID iDs:
// - Legacy sandbox IDs: start with "sandbox-"
// - Sandbox ORCID pattern: starts with "0009-"
if (orcidId.startsWith('sandbox-') || orcidId.startsWith('0009-')) {
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);
}

Copilot uses AI. Check for mistakes.
}

return accountIds;
}
Comment on lines +263 to +301
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new function getAllPossibleOrcidAccountIds lacks direct unit test coverage. Given that this codebase uses comprehensive automated testing with vitest, and this is a critical function that handles ORCID account ID generation with complex logic for backwards compatibility, it should have dedicated unit tests. Consider adding tests that verify: 1) production ORCID IDs generate correct account IDs, 2) sandbox ORCID IDs with "sandbox-" prefix are handled correctly, 3) the deduplication logic works when both sourceIds produce the same account ID, and 4) error handling for unavailable chains.

Copilot uses AI. Check for mistakes.
37 changes: 22 additions & 15 deletions src/linked-identity/linkedIdentityResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
Comment on lines +111 to +121
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolver now makes sequential database queries for each candidate account ID until a match is found. In the worst case with sandbox ORCIDs, this means two sequential database lookups per request. For better performance, consider fetching all candidate account IDs in a single batched query using SQL IN clause or Promise.all with individual queries, then returning the first non-null result. This would reduce latency, especially on high-latency database connections.

Copilot uses AI. Check for mistakes.

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);
Comment on lines +123 to +125
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While getAllPossibleOrcidAccountIds always returns at least one account ID (the legacy sourceId=2 variant), the code would be more robust with an explicit check before accessing candidateAccountIds[0] on line 124. Consider adding an assertion or throw statement if the array is unexpectedly empty, or adding a comment explaining why the array is guaranteed to be non-empty. This improves code maintainability and makes the assumption explicit.

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +125
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing integration tests only mock getAllPossibleOrcidAccountIds to return a single account ID. To properly test the new multi-candidate resolution logic, additional test cases should be added that mock getAllPossibleOrcidAccountIds to return multiple candidate IDs and verify: 1) the resolver tries each candidate in order, 2) it returns the first match found, 3) when no match is found, it correctly falls back to toFakeUnclaimedOrcid with the first candidate. These scenarios are critical for ensuring the backwards compatibility feature works correctly.

Copilot uses AI. Check for mistakes.
},
},
OrcidLinkedIdentity: {
Expand Down
33 changes: 16 additions & 17 deletions src/orcid-account/orcidAccountIdUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +7 to +8
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment describing sourceId=2 as "regular ORCID" is misleading. According to the PR description and the code in getAllPossibleOrcidAccountIds, sourceId=2 is used for both regular production ORCID accounts AND legacy sandbox accounts (with "sandbox-" prefix). The comment should clarify that sourceId=2 is used for all legacy ORCID accounts (both production and sandbox with prefix), while sourceId=4 is specifically for Lit-claimed sandbox accounts without the prefix.

Suggested change
* - 2: regular ORCID
* - 4: sandbox ORCID (Lit oracle uses a separate sourceId instead of a name prefix)
* - 2: legacy ORCID accounts (both production ORCID and legacy sandbox accounts with "sandbox-" prefix)
* - 4: Lit-claimed sandbox ORCID accounts without the "sandbox-" prefix (use separate sourceId instead)

Copilot uses AI. Check for mistakes.
*/
export const ORCID_SOURCE_IDS = [2, 4];
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file tests/orcid-account/orcidAccountIdUtils.test.ts imports ORCID_FORGE_ID which was removed in this PR and replaced with ORCID_SOURCE_IDS. This change will break the existing test suite when this PR is merged. The test file needs to be updated to import and test ORCID_SOURCE_IDS instead, and the test should verify that it contains both 2 and 4.

Copilot uses AI. Check for mistakes.

/**
* 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;
Expand Down
14 changes: 7 additions & 7 deletions tests/linked-identity/linkedIdentityResolvers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
);
Expand All @@ -211,7 +211,7 @@ describe('linkedIdentityResolvers', () => {

expect(validateOrcidExists).toHaveBeenCalledWith(orcid);

expect(getCrossChainOrcidAccountIdByOrcidId).toHaveBeenCalledWith(orcid, [
expect(getAllPossibleOrcidAccountIds).toHaveBeenCalledWith(orcid, [
'mainnet',
]);

Expand Down Expand Up @@ -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
);
Expand All @@ -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(
Expand Down