Skip to content

Commit 700d684

Browse files
author
Morgan Brown
committed
First pass orcid owner update endpoint
1 parent a0b2779 commit 700d684

File tree

2 files changed

+195
-11
lines changed

2 files changed

+195
-11
lines changed
Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
import { fetchOrcid } from "./fetch-orcid";
1+
import { fetchOrcid } from './fetch-orcid';
2+
3+
export function getClaimingUrlAddress(claimingUrl: string) {
4+
let claimingUrlParsed;
5+
try {
6+
claimingUrlParsed = new URL(claimingUrl);
7+
} catch {
8+
return null;
9+
}
10+
11+
return claimingUrlParsed.searchParams.get('ethereum_owned_by') ?? '';
12+
}
213

314
export default async function verifyOrcidClaim(orcidId: string, expectedAddress: string) {
415
const orcidProfile = await fetchOrcid(orcidId, fetch);
516
if (!orcidProfile) {
6-
throw new Error(
7-
'Unable to find ORCID profile. Is it public?'
8-
);
17+
throw new Error('Unable to find ORCID profile. Is it public?');
918
}
1019

1120
if (!orcidProfile.claimingUrl) {
@@ -14,21 +23,17 @@ export default async function verifyOrcidClaim(orcidId: string, expectedAddress:
1423
);
1524
}
1625

17-
let claimingUrl;
18-
try {
19-
claimingUrl = new URL(orcidProfile.claimingUrl)
20-
} catch(error) {
21-
console.error('ORCID claiming URL invalid', error)
26+
const urlAddress = getClaimingUrlAddress(orcidProfile.claimingUrl);
27+
if (urlAddress === null) {
2228
// TODO: refine
2329
throw new Error(
2430
'The link in your ORCID profile is invalid. Ensure that it matches http://0.0.0.0/?ethereum_owned_by={eth address}&orcid={ORCID iD}',
2531
);
2632
}
2733

28-
const urlAddress = claimingUrl.searchParams.get('ethereum_owned_by') || ''
2934
if (urlAddress.toLowerCase() !== expectedAddress.toLowerCase()) {
3035
throw new Error(
3136
'Expected Ethereum address not found in link. If you just edited the file, it may take a few moments for GitHub to process your changes, so please try again in a minute.',
3237
);
3338
}
34-
}
39+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { z } from 'zod';
2+
import type { RequestHandler } from './$types';
3+
import { error } from '@sveltejs/kit';
4+
import { ethers, toUtf8Bytes } from 'ethers';
5+
import unreachable from '$lib/utils/unreachable';
6+
import { GelatoRelay, type SponsoredCallRequest } from '@gelatonetwork/relay-sdk';
7+
import assert from '$lib/utils/assert';
8+
import network from '$lib/stores/wallet/network';
9+
import { gql } from 'graphql-request';
10+
import query from '$lib/graphql/dripsQL';
11+
import type {
12+
IsOrcidUnclaimedQuery,
13+
IsOrcidUnclaimedQueryVariables,
14+
} from './__generated__/gql.generated';
15+
import { redis } from '../../../redis';
16+
import getOptionalEnvVar from '$lib/utils/get-optional-env-var/private';
17+
import { JsonRpcProvider } from 'ethers';
18+
import isClaimed from '$lib/utils/orcids/is-claimed';
19+
import { fetchOrcid } from '$lib/utils/orcids/fetch-orcid';
20+
import { getClaimingUrlAddress } from '$lib/utils/orcids/verify-orcid';
21+
import { Forge } from '$lib/utils/sdk/sdk-types';
22+
23+
const GELATO_API_KEY = getOptionalEnvVar(
24+
'GELATO_API_KEY',
25+
true,
26+
"Gasless transactions won't work." +
27+
"This means that claiming a project won't and collecting funds (on networks supporting gasless TXs and with gasless TXs enabled in settings) won't work.",
28+
);
29+
30+
const payloadSchema = z.object({
31+
orcid: z.string(),
32+
chainId: z.number(),
33+
});
34+
35+
const REPO_DRIVER_ABI = `[
36+
{
37+
"inputs": [
38+
{ "internalType": "enum Forge", "name": "forge", "type": "uint8" },
39+
{ "internalType": "bytes", "name": "name", "type": "bytes" }
40+
],
41+
"name": "requestUpdateOwner",
42+
"outputs": [{ "internalType": "uint256", "name": "accountId", "type": "uint256" }],
43+
"stateMutability": "nonpayable",
44+
"type": "function"
45+
},
46+
]`;
47+
48+
const orcidUnclaimedQuery = gql`
49+
query isOrcidUnclaimed($orcid: String!, $chain: SupportedChain!) {
50+
orcidLinkedIdentityByOrcid(orcid: $orcid, chain: $chain) {
51+
chain
52+
isClaimed
53+
areSplitsValid
54+
owner {
55+
address
56+
}
57+
}
58+
}
59+
`;
60+
61+
export const POST: RequestHandler = async ({ request, fetch }) => {
62+
assert(
63+
GELATO_API_KEY,
64+
'GELATO_API_KEY is required. Gasless transactions will not work without it.',
65+
);
66+
67+
assert(
68+
redis,
69+
'This endpoint requires a connected Redis instance. Ensure CACHE_REDIS_CONNECTION_STRING is set in env',
70+
);
71+
72+
let payload: z.infer<typeof payloadSchema>;
73+
74+
try {
75+
const body = await request.text();
76+
payload = payloadSchema.parse(JSON.parse(body));
77+
} catch {
78+
error(400, 'Invalid payload');
79+
}
80+
81+
// eslint-disable-next-line no-console
82+
console.log('REPO_OWNER_UPDATE', payload);
83+
84+
const { orcid, chainId } = payload;
85+
86+
assert(network.chainId === chainId, 'Unsupported chain id');
87+
88+
const isOrcidUnclaimedQueryResponse = await query<
89+
IsOrcidUnclaimedQuery,
90+
IsOrcidUnclaimedQueryVariables
91+
>(
92+
orcidUnclaimedQuery,
93+
{
94+
orcid: orcid,
95+
chain: network.gqlName,
96+
},
97+
fetch,
98+
);
99+
100+
const orcidAccount = isOrcidUnclaimedQueryResponse.orcidLinkedIdentityByOrcid;
101+
if (!orcidAccount) {
102+
return error(400, 'ORCID not found');
103+
}
104+
105+
if (isClaimed(orcidAccount)) {
106+
return error(400, 'Orcid already claimed');
107+
}
108+
109+
const orcidProfile = await fetchOrcid(orcid, fetch);
110+
// TODO: yowza, do we need to think about this more? If we can fetch the account, but not
111+
// the profile, what's going on?
112+
if (!orcidProfile) {
113+
return error(400, 'Orcid unfetchable');
114+
}
115+
116+
const urlAddress = getClaimingUrlAddress(orcidProfile.claimingUrl);
117+
if (
118+
orcidAccount.owner?.address &&
119+
orcidAccount.owner.address.toLowerCase() === urlAddress?.toLowerCase()
120+
) {
121+
return new Response('{ "taskId": null }');
122+
}
123+
124+
const blockKey = `${network.name}-ownerUpdateRequest-${orcid}`;
125+
const blockRecordTaskId = await redis.get(blockKey);
126+
127+
if (blockRecordTaskId) {
128+
const taskStatusRes = await fetch(`/api/gasless/track/${blockRecordTaskId}`);
129+
if (!taskStatusRes.ok)
130+
throw new Error(`Failed to fetch task status: ${await taskStatusRes.text()}`);
131+
132+
const { task } = await taskStatusRes.json();
133+
assert(typeof task === 'object', 'Invalid task');
134+
const { taskState } = task;
135+
assert(typeof taskState === 'string', 'Invalid task state');
136+
137+
if (['CheckPending', 'ExecPending', 'WaitingForConfirmation'].includes(taskState)) {
138+
// A request is already in-flight
139+
return new Response(JSON.stringify({ taskId: blockRecordTaskId }));
140+
} else {
141+
await redis.del(blockKey);
142+
}
143+
}
144+
145+
const provider = new JsonRpcProvider(network.rpcUrl);
146+
const contract = new ethers.Contract(network.contracts.REPO_DRIVER, REPO_DRIVER_ABI, provider);
147+
148+
const tx = await contract.requestUpdateOwner.populateTransaction(
149+
Forge.orcidId,
150+
ethers.hexlify(toUtf8Bytes(orcid)),
151+
);
152+
153+
const relayRequest: SponsoredCallRequest = {
154+
chainId: BigInt(chainId),
155+
target: tx.to ?? unreachable(),
156+
data: tx.data ?? unreachable(),
157+
};
158+
159+
const relay = new GelatoRelay();
160+
161+
try {
162+
const relayResponse = await relay.sponsoredCall(relayRequest, GELATO_API_KEY);
163+
const { taskId } = relayResponse;
164+
165+
// eslint-disable-next-line no-console
166+
console.log('RELAY_RESPONSE', payload, relayResponse);
167+
168+
redis.set(blockKey, taskId, {
169+
// 4 hours
170+
EX: 4 * 60 * 60,
171+
});
172+
173+
return new Response(JSON.stringify(relayResponse));
174+
} catch (e) {
175+
// eslint-disable-next-line no-console
176+
console.error(e);
177+
return error(500, e instanceof Error ? e : 'Unknown error');
178+
}
179+
};

0 commit comments

Comments
 (0)