diff --git a/desci-server/src/controllers/nodes/consent.ts b/desci-server/src/controllers/nodes/consent.ts index b1a577ea7..ef7cee02b 100644 --- a/desci-server/src/controllers/nodes/consent.ts +++ b/desci-server/src/controllers/nodes/consent.ts @@ -11,6 +11,7 @@ export const consent = async (req: Request, res: Response, next: NextFunction) = ActionType.USER_TERMS_CONSENT, { ...req.body, + email: user.email, }, user?.id, ); diff --git a/desci-server/src/controllers/proxy/orcidProfile.ts b/desci-server/src/controllers/proxy/orcidProfile.ts index 7cee18de2..904bc325d 100644 --- a/desci-server/src/controllers/proxy/orcidProfile.ts +++ b/desci-server/src/controllers/proxy/orcidProfile.ts @@ -18,6 +18,32 @@ interface OrcidQueryResponseError { const ORCID_PROFILE_TTL = 1000 * 60 * 60 * 24; // 1 day const REFRESH_RATE_LIMIT = 1000 * 60 * 5; // 5 minutes +export const orcidDid = async (req: Request, res: Response) => { + const wallet = await prisma.wallet.findFirst({ + where: { + address: req.params.did, + }, + }); + if (!wallet) { + res.status(404).send({ ok: false }); + return; + } + const user = await prisma.user.findFirst({ + where: { + id: wallet.userId, + orcid: { + not: null, + }, + }, + }); + if (!user) { + res.status(404).send({ ok: false }); + return; + } + res.send({ orcid: user.orcid, did: wallet.address }); + return; +}; + export const orcidProfile = async ( req: Request, res: Response, diff --git a/desci-server/src/controllers/users/associateWallet.ts b/desci-server/src/controllers/users/associateWallet.ts index 99655f1a4..3a3fcc3d3 100755 --- a/desci-server/src/controllers/users/associateWallet.ts +++ b/desci-server/src/controllers/users/associateWallet.ts @@ -6,6 +6,7 @@ import { ErrorTypes, SiweMessage } from 'siwe'; import prisma from 'client'; import parentLogger from 'logger'; import { saveInteraction } from 'services/interactionLog'; +import { writeExternalIdToOrcidProfile } from 'services/user'; const createWalletNickname = async (user: Prisma.UserWhereInput) => { const count = await prisma.wallet.count({ @@ -19,6 +20,89 @@ const createWalletNickname = async (user: Prisma.UserWhereInput) => { return `Account #${count + 1}`; }; +export const associateOrcidWallet = async (req: Request, res: Response, next: NextFunction) => { + const logger = parentLogger.child({ + module: 'USERS::softAssociateWalletController', + user: (req as any).user, + body: req.body, + }); + + // associate without siwe check (only necessary for direct DID login, which is unsupported for ORCID DIDs right now) + try { + const user = (req as any).user; + const { did } = req.body; + if (!did) { + res.status(400).send({ err: 'missing wallet address' }); + return; + } + const doesExist = + (await prisma.wallet.count({ + where: { + address: did, + }, + })) > 0; + if (doesExist) { + res.status(400).send({ err: 'duplicate DID (global)' }); + return; + } + const ORCID_NICKNAME = 'ORCID'; + const hasOrcidWallet = await prisma.wallet.count({ + where: { + user, + nickname: ORCID_NICKNAME, + }, + }); + if (hasOrcidWallet > 0) { + res.status(400).send({ err: 'orcid DID already registered' }); + return; + } + + try { + const addWallet = await prisma.wallet.create({ + data: { address: did, userId: user.id, nickname: ORCID_NICKNAME }, + }); + saveInteraction( + req, + ActionType.USER_WALLET_ASSOCIATE, + { + addr: did, + orcid: req.body.orcid, + orcidUser: user.orcid, + }, + user.id, + ); + // check if orcid associated + // add to orcid profile in the did:pkh format + // did:pkh:eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a + // POST to orcid API as an External Identifier + if (user.orcid) { + console.log('adding to orcid profile'); + await writeExternalIdToOrcidProfile(user.id, did); + console.log('done writing'); + } + + try { + const hash = await sendGiftTxn(user, did, addWallet.id); + res.send({ ok: true, gift: hash }); + return; + } catch (err) { + logger.error({ err }, 'Error sending orcid DID txn'); + } + res.send({ ok: true }); + return; + // req.session.save(() => res.status(200).send({ ok: true })); + } catch (err) { + logger.error({ err }, 'Error associating orcid DID to user #1'); + res.status(500).send({ err }); + return; + } + } catch (e) { + logger.error({ err: e }, 'Error associating orcid DID to user #2'); + + res.status(500).json({ message: e.message }); + } +}; + export const associateWallet = async (req: Request, res: Response, next: NextFunction) => { const logger = parentLogger.child({ module: 'USERS::associateWalletController', @@ -72,57 +156,12 @@ export const associateWallet = async (req: Request, res: Response, next: NextFun }, user.id, ); - if (process.env.HOT_WALLET_KEY) { - /** - * Auto send user ETH - */ - const giftedWallets = await prisma.wallet.count({ - where: { - user, - giftTransaction: { not: null }, - }, - }); - if (giftedWallets === 0) { - let provider; - try { - provider = new ethers.providers.JsonRpcProvider( - process.env.NODE_ENV === 'production' - ? 'https://eth-goerli.g.alchemy.com/v2/ZeIzCAJyPpRnTtPNSmddHGF-q2yp-2Uy' - : 'http://host.docker.internal:8545', - ); - const bn = await provider.getBlockNumber(); - - const privateKey = process.env.HOT_WALLET_KEY; - const wallet = new ethers.Wallet(privateKey, provider); - const receiverAddress = walletAddress; - // Ether amount to send - const amountInEther = '0.005'; - // Create a transaction object - const tx = { - to: receiverAddress, - // Convert currency unit from ether to wei - value: ethers.utils.parseEther(amountInEther), - }; - - // Send a transaction - const txObj = await wallet.sendTransaction(tx); - - await prisma.wallet.update({ - where: { - id: addWallet.id, - }, - data: { - giftTransaction: txObj.hash, - usedFaucet: true, - }, - }); - logger.info(`gifted user id ${user.id} txHash ${txObj.hash}`); - res.send({ ok: true, gift: txObj.hash }); - return; - } catch (err) { - logger.error({ err }, 'failed to connect to blockchain RPC, sending funds failed'); - } - } + + try { + const hash = await sendGiftTxn(user, walletAddress, addWallet.id); + res.send({ ok: true, gift: hash }); + } catch (err) { + logger.error({ err }, 'Error sending gift txn'); } res.send({ ok: true }); // req.session.save(() => res.status(200).send({ ok: true })); @@ -156,3 +195,64 @@ export const associateWallet = async (req: Request, res: Response, next: NextFun } } }; + +const sendGiftTxn = async (user: User, walletAddress: string, addedWalletId: number) => { + const logger = parentLogger.child({ + module: 'USERS::associateWallet::sendGiftTxn', + user, + walletAddress, + }); + if (process.env.HOT_WALLET_KEY) { + /** + * Auto send user ETH + */ + const giftedWallets = await prisma.wallet.count({ + where: { + user, + giftTransaction: { not: null }, + }, + }); + if (giftedWallets === 0) { + let provider; + try { + provider = new ethers.providers.JsonRpcProvider( + process.env.NODE_ENV === 'production' + ? 'https://eth-goerli.g.alchemy.com/v2/ZeIzCAJyPpRnTtPNSmddHGF-q2yp-2Uy' + : 'http://host.docker.internal:8545', + ); + const bn = await provider.getBlockNumber(); + + const privateKey = process.env.HOT_WALLET_KEY; + const wallet = new ethers.Wallet(privateKey, provider); + const receiverAddress = walletAddress; + // Ether amount to send + const amountInEther = '0.005'; + // Create a transaction object + const tx = { + to: receiverAddress, + // Convert currency unit from ether to wei + value: ethers.utils.parseEther(amountInEther), + }; + + // Send a transaction + const txObj = await wallet.sendTransaction(tx); + + await prisma.wallet.update({ + where: { + id: addedWalletId, + }, + data: { + giftTransaction: txObj.hash, + usedFaucet: true, + }, + }); + logger.info(`gifted user id ${user.id} txHash ${txObj.hash}`); + + return txObj.hash; + } catch (err) { + logger.error({ err }, 'failed to connect to blockchain RPC, sending funds failed'); + } + } + } + return null; +}; diff --git a/desci-server/src/routes/v1/services.ts b/desci-server/src/routes/v1/services.ts index 7e4d216f5..a3b260003 100644 --- a/desci-server/src/routes/v1/services.ts +++ b/desci-server/src/routes/v1/services.ts @@ -1,9 +1,10 @@ import { Router } from 'express'; -import { orcidProfile } from 'controllers/proxy/orcidProfile'; +import { orcidDid, orcidProfile } from 'controllers/proxy/orcidProfile'; const router = Router(); router.get('/orcid/profile/:orcidId', [], orcidProfile); +router.get('/orcid/did/:did', [], orcidDid); export default router; diff --git a/desci-server/src/routes/v1/users.ts b/desci-server/src/routes/v1/users.ts index 5963a6beb..e50835d44 100755 --- a/desci-server/src/routes/v1/users.ts +++ b/desci-server/src/routes/v1/users.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; -import { list, associateWallet, updateProfile } from 'controllers/users'; +import { list, associateWallet, updateProfile, associateOrcidWallet } from 'controllers/users'; import { usage } from 'controllers/users/usage'; import { checkJwt } from 'middleware/checkJwt'; import { ensureAdmin } from 'middleware/ensureAdmin'; @@ -11,6 +11,7 @@ const router = Router(); router.get('/usage', [ensureUser], usage); router.get('/', [ensureUser, ensureAdmin], list); router.post('/associate', [ensureUser], associateWallet); +router.post('/orcid/associate', [ensureUser], associateOrcidWallet); router.patch('/updateProfile', [ensureUser], updateProfile); export default router; diff --git a/desci-server/src/services/user.ts b/desci-server/src/services/user.ts index 372e2ebcc..e0561fe2c 100644 --- a/desci-server/src/services/user.ts +++ b/desci-server/src/services/user.ts @@ -1,4 +1,5 @@ import { ActionType, AuthToken, AuthTokenSource, User, prisma } from '@prisma/client'; +import axios from 'axios'; import { OrcIdRecordData, generateAccessToken, getOrcidRecord } from 'controllers/auth'; import parentLogger from 'logger'; @@ -64,6 +65,63 @@ export async function isAuthTokenSetForUser(userId: number): Promise { return !!authToken; } +export async function writeExternalIdToOrcidProfile(userId: number, didAddress: string) { + const user = await client.user.findFirst({ + where: { + id: userId, + }, + }); + if (!user.orcid) { + throw new Error('User does not have an orcid'); + } + const authToken = await client.authToken.findFirst({ + where: { + userId, + source: AuthTokenSource.ORCID, + }, + }); + if (!authToken) { + throw new Error('User does not have an orcid auth token'); + } + // check if it's already written to orcid + const headers = { + 'Content-Type': 'application/vnd.orcid+json', + Authorization: `Bearer ${authToken.accessToken}`, + }; + const orcidId = user.orcid; + const fullDid = `did:pkh:eip155:1:${didAddress}`; + try { + const externalIds = await axios.get( + `https://api.${process.env.ORCID_API_DOMAIN}/v3.0/${orcidId}/external-identifiers`, + { headers }, + ); + if (externalIds.data['external-identifier'].some((id) => id['external-id-value'] === fullDid)) { + console.log('External ID already added'); + return; + } + debugger; + } catch (error) { + console.error('Error getting external IDs:', error.response?.data || error.message); + } + + const apiUrl = `https://api.${process.env.ORCID_API_DOMAIN}/v3.0/${orcidId}/external-identifiers`; + const externalIdPayload = { + 'external-id-type': 'Public Key', + 'external-id-value': fullDid, + 'external-id-url': { + value: `https://nodes.desci.com/orcid-did/${didAddress}`, + }, + 'external-id-relationship': 'self', + }; + + try { + const response = await axios.post(apiUrl, externalIdPayload, { headers }); + console.log('External ID added:', response.data); + } catch (error) { + console.error('Error adding external ID:', error.response?.data || error.message); + } +} + export async function connectOrcidToUserIfPossible( userId: number, orcid: string, @@ -72,7 +130,6 @@ export async function connectOrcidToUserIfPossible( expiresIn: number, orcidLookup: (orcid: string, accessToken: string) => Promise = getOrcidRecord, ) { - debugger; logger.info({ fn: 'connectOrcidToUserIfPossible', orcid, accessTokenPresent: !!accessToken }, `doing orcid lookup`); const orcidRecord = await orcidLookup(orcid, accessToken); logger.info({ fn: 'connectOrcidToUserIfPossible', orcidRecord, orcid }, `found orcid record`);