diff --git a/src/features/incentive/PointSystem.ts b/src/features/incentive/PointSystem.ts index 44f765bab..f566d4f09 100644 --- a/src/features/incentive/PointSystem.ts +++ b/src/features/incentive/PointSystem.ts @@ -10,7 +10,7 @@ import { Twitter } from "@/libs/identity/tools/twitter" import { UDIdentityManager } from "@/libs/blockchain/gcr/gcr_routines/udIdentityManager" import { SavedUdIdentity } from "@/model/entities/types/IdentityTypes" import { UserPoints } from "@kynesyslabs/demosdk/abstraction" -import { NomisWalletIdentity } from "@/model/entities/types/IdentityTypes" +import { NomisWalletIdentity, EthosWalletIdentity } from "@/model/entities/types/IdentityTypes" const pointValues = { LINK_WEB3_WALLET: 0.5, @@ -45,6 +45,7 @@ export class PointSystem { [network: string]: string[] } linkedNomis: NomisWalletIdentity[] + linkedEthos: EthosWalletIdentity[] }> { const identities = await IdentityManager.getIdentities(userId) const twitterIdentities = await IdentityManager.getWeb2Identities( @@ -119,6 +120,31 @@ export class PointSystem { } } + const linkedEthos: EthosWalletIdentity[] = [] + + if (identities?.ethos) { + const ethosChains = Object.keys(identities.ethos) + + for (const chain of ethosChains) { + const subChains = identities.ethos[chain] + const subChainKeys = Object.keys(subChains) + + for (const subChain of subChainKeys) { + const ethosIdentities = subChains[subChain] + + if (Array.isArray(ethosIdentities)) { + const mapped = ethosIdentities.map(ethosIdentity => ({ + chain, + subchain: subChain, + ...ethosIdentity, + })) + + linkedEthos.push(...mapped) + } + } + } + } + const linkedSocials: { twitter?: string github?: string @@ -159,7 +185,7 @@ export class PointSystem { } } - return { linkedWallets, linkedSocials, linkedUDDomains, linkedNomis } + return { linkedWallets, linkedSocials, linkedUDDomains, linkedNomis, linkedEthos } } /** @@ -175,7 +201,7 @@ export class PointSystem { const gcrMainRepository = db.getDataSource().getRepository(GCRMain) let account = await gcrMainRepository.findOneBy({ pubkey: userIdStr }) - const { linkedWallets, linkedSocials, linkedUDDomains, linkedNomis } = + const { linkedWallets, linkedSocials, linkedUDDomains, linkedNomis, linkedEthos } = await this.getUserIdentitiesFromGCR(userIdStr) if (!account) { @@ -212,6 +238,7 @@ export class PointSystem { }, udDomains: account.points.breakdown?.udDomains || {}, nomisScores: account.points.breakdown?.nomisScores || {}, + ethosScores: account.points.breakdown?.ethosScores || {}, referrals: account.points.breakdown?.referrals || 0, demosFollow: account.points.breakdown?.demosFollow || 0, }, @@ -219,6 +246,7 @@ export class PointSystem { linkedSocials, linkedUDDomains, linkedNomisIdentities: linkedNomis, + linkedEthosIdentities: linkedEthos, lastUpdated: account.points.lastUpdated || new Date(), flagged: account.flagged || null, flaggedReason: account.flaggedReason || null, @@ -236,6 +264,7 @@ export class PointSystem { | "socialAccounts" | "udDomains" | "nomisScores" + | "ethosScores" | "demosFollow", platform: string, referralCode?: string, @@ -253,6 +282,7 @@ export class PointSystem { referrals: 0, demosFollow: 0, nomisScores: {}, + ethosScores: {}, } const oldTotal = account.points.totalPoints || 0 @@ -302,6 +332,15 @@ export class PointSystem { const newChainPoints = Math.max(0, oldChainPoints + points) appliedDelta = newChainPoints - oldChainPoints account.points.breakdown.nomisScores[platform] = newChainPoints + } else if (type === "ethosScores") { + account.points.breakdown.ethosScores = + account.points.breakdown.ethosScores || {} + const oldChainPoints = + account.points.breakdown.ethosScores[platform] || 0 + + const newChainPoints = Math.max(0, oldChainPoints + points) + appliedDelta = newChainPoints - oldChainPoints + account.points.breakdown.ethosScores[platform] = newChainPoints } else if (type === "demosFollow") { const oldDemosFollowPoints = account.points.breakdown.demosFollow || 0 @@ -1557,4 +1596,210 @@ export class PointSystem { if (formattedScore >= 20) return 2 return 1 } + + /** + * Award points for linking an Ethos score + * @param userId The user's Demos address + * @param chain The chain type (must be "evm") + * @param ethosScore The Ethos reputation score (0-2800) + * @param referralCode Optional referral code + * @returns RPCResponse + */ + async awardEthosScorePoints( + userId: string, + chain: string, + ethosScore: number, + referralCode?: string, + ): Promise { + const validChains = ["evm"] + const invalidChainMessage = + "Invalid Ethos chain. Allowed values are 'evm'." + const ethosScoreAlreadyLinkedMessage = `An Ethos score for ${chain} is already linked.` + + try { + if (!validChains.includes(chain)) { + return { + result: 400, + response: invalidChainMessage, + require_reply: false, + extra: null, + } + } + + const userPointsWithIdentities = await this.getUserPointsInternal( + userId, + ) + + if (!userPointsWithIdentities.linkedSocials.twitter) { + return { + result: 400, + response: "Twitter account not linked. Not awarding points", + require_reply: false, + extra: null, + } + } + + const hasEvmWallet = + userPointsWithIdentities.linkedWallets.some(w => + w.startsWith("evm:"), + ) + + if (!hasEvmWallet) { + return { + result: 400, + response: + "EVM wallet not linked. Cannot award Ethos points", + require_reply: false, + extra: null, + } + } + + const existingEthosScoreOnChain = + userPointsWithIdentities.breakdown.ethosScores?.[chain] + + if ( + existingEthosScoreOnChain != null && + existingEthosScoreOnChain > 0 + ) { + const updatedPoints = await this.getUserPointsInternal(userId) + + return { + result: 400, + response: { + pointsAwarded: 0, + totalPoints: updatedPoints.totalPoints, + message: ethosScoreAlreadyLinkedMessage, + }, + require_reply: false, + extra: {}, + } + } + + const pointsToAward = this.getEthosPointsByScore(ethosScore) + + await this.addPointsToGCR( + userId, + pointsToAward, + "ethosScores", + chain, + referralCode, + ) + + const updatedPoints = await this.getUserPointsInternal(userId) + + log.info( + `[EthosPoints] AWARDED: account=${userId.substring(0, 16)}..., chain=${chain}, ethosScore=${ethosScore}, pointsAwarded=${pointsToAward}, totalPoints=${updatedPoints.totalPoints}`, + ) + + return { + result: 200, + response: { + pointsAwarded: pointsToAward, + totalPoints: updatedPoints.totalPoints, + message: `Points awarded for linking Ethos score on ${chain}`, + }, + require_reply: false, + extra: {}, + } + } catch (error) { + return { + result: 500, + response: "Error awarding Ethos score points", + require_reply: false, + extra: { + error: + error instanceof Error ? error.message : String(error), + }, + } + } + } + + /** + * Deduct points for unlinking an Ethos score + * @param userId The user's Demos address + * @param chain The chain type (must be "evm") + * @returns RPCResponse + */ + async deductEthosScorePoints( + userId: string, + chain: string, + ): Promise { + const validChains = ["evm"] + const invalidChainMessage = + "Invalid Ethos chain. Allowed values are 'evm'." + + try { + if (!validChains.includes(chain)) { + return { + result: 400, + response: invalidChainMessage, + require_reply: false, + extra: null, + } + } + + const account = await ensureGCRForUser(userId) + const currentEthosForChain = + account.points.breakdown?.ethosScores?.[chain] ?? 0 + + if (currentEthosForChain <= 0) { + const userPointsWithIdentities = + await this.getUserPointsInternal(userId) + return { + result: 200, + response: { + pointsDeducted: 0, + totalPoints: userPointsWithIdentities.totalPoints, + message: `No Ethos points to deduct for ${chain}`, + }, + require_reply: false, + extra: {}, + } + } + + const pointsToDeduct = currentEthosForChain + + await this.addPointsToGCR( + userId, + -pointsToDeduct, + "ethosScores", + chain, + ) + + const updatedPoints = await this.getUserPointsInternal(userId) + + log.info( + `[EthosPoints] DEDUCTED: account=${userId.substring(0, 16)}..., chain=${chain}, pointsDeducted=${pointsToDeduct}, totalPoints=${updatedPoints.totalPoints}`, + ) + + return { + result: 200, + response: { + pointsDeducted: pointsToDeduct, + totalPoints: updatedPoints.totalPoints, + message: `Points deducted for unlinking Ethos score on ${chain}`, + }, + require_reply: false, + extra: {}, + } + } catch (error) { + return { + result: 500, + response: "Error deducting Ethos score points", + require_reply: false, + extra: { + error: + error instanceof Error ? error.message : String(error), + }, + } + } + } + + private getEthosPointsByScore(score: number): number { + if (score >= 2000) return 5 + if (score >= 1600) return 4 + if (score >= 1200) return 3 + if (score >= 800) return 2 + return 1 + } } diff --git a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts index 1467d20a7..caa842785 100644 --- a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts +++ b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts @@ -13,13 +13,16 @@ import ensureGCRForUser from "./ensureGCRForUser" import Hashing from "@/libs/crypto/hashing" import { NomisWalletIdentity, + EthosWalletIdentity, PqcIdentityEdit, SavedNomisIdentity, + SavedEthosIdentity, SavedXmIdentity, SavedUdIdentity, } from "@/model/entities/types/IdentityTypes" import log from "@/utilities/logger" import { IncentiveManager } from "./IncentiveManager" +import { EthosApiClient } from "@/libs/identity/tools/ethos" export default class GCRIdentityRoutines { // SECTION XM Identity Routines @@ -869,6 +872,20 @@ export default class GCRIdentityRoutines { simulate, ) break + case "ethosadd": + result = await this.applyEthosIdentityUpsert( + identityEdit, + gcrMainRepository, + simulate, + ) + break + case "ethosremove": + result = await this.applyEthosIdentityRemove( + identityEdit, + gcrMainRepository, + simulate, + ) + break default: result = { success: false, @@ -887,7 +904,8 @@ export default class GCRIdentityRoutines { | "telegram" | "discord" | "ud" - | "nomis", + | "nomis" + | "ethos", data: { userId?: string // for twitter/github/discord chain?: string // for web3 @@ -898,7 +916,7 @@ export default class GCRIdentityRoutines { gcrMainRepository: Repository, currentAccount?: string, ): Promise { - if (type !== "web3" && type !== "ud" && type !== "nomis") { + if (type !== "web3" && type !== "ud" && type !== "nomis" && type !== "ethos") { // Handle web2 identity types: twitter, github, telegram, discord const queryTemplate = ` EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(gcr.identities->'web2'->'${type}', '[]'::jsonb)) as ${type}_id WHERE ${type}_id->>'userId' = :userId) @@ -938,7 +956,7 @@ export default class GCRIdentityRoutines { const addressToCheck = data.chain === "evm" ? data.address.toLowerCase() : data.address - const rootKey = type === "web3" ? "xm" : "nomis" + const rootKey = type === "web3" ? "xm" : type === "ethos" ? "ethos" : "nomis" const result = await gcrMainRepository .createQueryBuilder("gcr") @@ -1122,4 +1140,193 @@ export default class GCRIdentityRoutines { return { success: true, message: "Nomis identity removed" } } + + // SECTION Ethos Identity Routines + + private static normalizeEthosAddress( + chain: string, + address: string, + ): string { + if (chain === "evm") { + return address.trim().toLowerCase() + } + + return address.trim() + } + + static async applyEthosIdentityUpsert( + editOperation: any, + gcrMainRepository: Repository, + simulate: boolean, + ): Promise { + const { + chain, + subchain, + address, + } = editOperation.data + + if (!chain || !subchain || !address) { + return { success: false, message: "Invalid Ethos identity payload: missing chain, subchain or address" } + } + + const normalizedAddress = this.normalizeEthosAddress(chain, address) + + // Fetch authoritative score from Ethos API server-side + const ethosClient = EthosApiClient.getInstance() + let serverScore: number + let serverProfileId: number | undefined + let serverMetadata: { displayName?: string; username?: string } | undefined + + try { + const ethosData = await ethosClient.getScore(normalizedAddress) + serverScore = ethosData.score + serverProfileId = ethosData.profileId + serverMetadata = { + displayName: ethosData.displayName, + username: ethosData.username, + } + } catch (error: any) { + log.error(`[GCRIdentityRoutines] Failed to fetch Ethos score from API`) + return { success: false, message: "Failed to fetch Ethos score" } + } + + const isFirst = await this.isFirstConnection( + "ethos", + { + chain: chain, + subchain: subchain, + address: normalizedAddress, + }, + gcrMainRepository, + editOperation.account, + ) + + const accountGCR = await ensureGCRForUser(editOperation.account) + + accountGCR.identities.ethos = accountGCR.identities.ethos || {} + accountGCR.identities.ethos[chain] = + accountGCR.identities.ethos[chain] || {} + accountGCR.identities.ethos[chain][subchain] = + accountGCR.identities.ethos[chain][subchain] || [] + + const chainBucket = accountGCR.identities.ethos[chain][subchain] + + const filtered = chainBucket.filter(existing => { + const existingAddress = this.normalizeEthosAddress( + chain, + existing.address, + ) + return existingAddress !== normalizedAddress + }) + + const record: SavedEthosIdentity = { + address: normalizedAddress, + score: serverScore, + profileId: serverProfileId, + lastSyncedAt: new Date().toISOString(), + metadata: serverMetadata, + } + + filtered.push(record) + accountGCR.identities.ethos[chain][subchain] = filtered + + if (!simulate) { + await gcrMainRepository.save(accountGCR) + + log.info( + `[EthosIdentity] LINKED: account=${accountGCR.pubkey.substring(0, 16)}..., chain=${chain}, subchain=${subchain}, score=${serverScore}, isFirstConnection=${isFirst}`, + ) + + if (isFirst) { + await IncentiveManager.ethosLinked( + accountGCR.pubkey, + chain, + serverScore, + editOperation.referralCode, + ) + } + } + + return { success: true, message: "Ethos identity upserted" } + } + + static async applyEthosIdentityRemove( + editOperation: any, + gcrMainRepository: Repository, + simulate: boolean, + ): Promise { + const identity = editOperation.data as EthosWalletIdentity + + if (!identity?.chain || !identity?.subchain || !identity?.address) { + return { success: false, message: "Invalid Ethos identity payload" } + } + + const normalizedAddress = this.normalizeEthosAddress( + identity.chain, + identity.address, + ) + + const accountGCR = await gcrMainRepository.findOneBy({ + pubkey: editOperation.account, + }) + + if (!accountGCR) { + return { success: false, message: "Account not found" } + } + + const chainBucket = + accountGCR.identities?.ethos?.[identity.chain]?.[identity.subchain] + + if (!Array.isArray(chainBucket)) { + return { success: false, message: "Ethos identity not found" } + } + + const exists = chainBucket.some(existing => { + const existingAddress = this.normalizeEthosAddress( + identity.chain, + existing.address, + ) + return existingAddress === normalizedAddress + }) + + if (!exists) { + return { success: false, message: "Ethos identity not found" } + } + + const filteredBucket = chainBucket.filter(existing => { + const existingAddress = this.normalizeEthosAddress( + identity.chain, + existing.address, + ) + return existingAddress !== normalizedAddress + }) + + accountGCR.identities.ethos[identity.chain][identity.subchain] = + filteredBucket + + if (!simulate) { + await gcrMainRepository.save(accountGCR) + + // Only deduct points if NO Ethos identities remain for this chain + // (checking all subchains, since points are tracked per-chain) + const chainIdentities = accountGCR.identities.ethos[identity.chain] + const hasRemainingIdentities = Object.values(chainIdentities).some( + subchainBucket => + Array.isArray(subchainBucket) && subchainBucket.length > 0, + ) + + log.info( + `[EthosIdentity] UNLINKED: account=${accountGCR.pubkey.substring(0, 16)}..., chain=${identity.chain}, subchain=${identity.subchain}, pointsDeducted=${!hasRemainingIdentities}`, + ) + + if (!hasRemainingIdentities) { + await IncentiveManager.ethosUnlinked( + accountGCR.pubkey, + identity.chain, + ) + } + } + + return { success: true, message: "Ethos identity removed" } + } } diff --git a/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts b/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts index 3b48087bb..4ab3059c0 100644 --- a/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts +++ b/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts @@ -191,4 +191,34 @@ export class IncentiveManager { chain, ) } + + /** + * Hook to be called after Ethos score linking + */ + static async ethosLinked( + userId: string, + chain: string, + ethosScore: number, + referralCode?: string, + ): Promise { + return await this.pointSystem.awardEthosScorePoints( + userId, + chain, + ethosScore, + referralCode, + ) + } + + /** + * Hook to be called after Ethos score unlinking + */ + static async ethosUnlinked( + userId: string, + chain: string, + ): Promise { + return await this.pointSystem.deductEthosScorePoints( + userId, + chain, + ) + } } diff --git a/src/libs/blockchain/gcr/gcr_routines/identityManager.ts b/src/libs/blockchain/gcr/gcr_routines/identityManager.ts index ab1e8f921..b08710cc3 100644 --- a/src/libs/blockchain/gcr/gcr_routines/identityManager.ts +++ b/src/libs/blockchain/gcr/gcr_routines/identityManager.ts @@ -22,7 +22,7 @@ import { PqcIdentityAssignPayload } from "node_modules/@kynesyslabs/demosdk/buil import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" import { CrossChainTools } from "@/libs/identity/tools/crosschain" import { chainIds } from "sdk/localsdk/multichain/configs/chainIds" -import { NomisWalletIdentity } from "@/model/entities/types/IdentityTypes" +import { NomisWalletIdentity, EthosWalletIdentity } from "@/model/entities/types/IdentityTypes" /* * Example of a payload for the gcr_routine method @@ -311,6 +311,33 @@ export default class IdentityManager { } } + /** + * Verify the payload for an Ethos identity assign payload. + * NOTE: This only validates required fields (chain, subchain, address). + * The score is intentionally NOT validated here - it is fetched server-side + * from the Ethos API in applyEthosIdentityUpsert() to prevent score spoofing. + * Any client-supplied score in the payload is ignored. + * + * @param payload - The payload to verify + * @returns {success: boolean, message: string} + */ + static async verifyEthosPayload( + payload: EthosWalletIdentity, + ): Promise<{ success: boolean; message: string }> { + if (!payload.chain || !payload.subchain || !payload.address) { + return { + success: false, + message: + "Invalid Ethos identity payload: missing chain, subchain or address", + } + } + + return { + success: true, + message: "Ethos identity payload verified", + } + } + // SECTION Helper functions and Getters /** * Get the identities related to a demos address @@ -355,7 +382,7 @@ export default class IdentityManager { */ static async getIdentities( address: string, - key?: "xm" | "web2" | "pqc" | "ud" | "nomis", + key?: "xm" | "web2" | "pqc" | "ud" | "nomis" | "ethos", ): Promise { const gcr = await ensureGCRForUser(address) if (key) { diff --git a/src/libs/identity/providers/ethosIdentityProvider.ts b/src/libs/identity/providers/ethosIdentityProvider.ts new file mode 100644 index 000000000..d9ef44a4c --- /dev/null +++ b/src/libs/identity/providers/ethosIdentityProvider.ts @@ -0,0 +1,127 @@ +import { GCRMain } from "@/model/entities/GCRv2/GCR_Main" +import ensureGCRForUser from "@/libs/blockchain/gcr/gcr_routines/ensureGCRForUser" +import { + EthosWalletIdentity, + SavedEthosIdentity, +} from "@/model/entities/types/IdentityTypes" +import { EthosApiClient } from "../tools/ethos" + +export type EthosIdentitySummary = EthosWalletIdentity + +export interface EthosImportOptions { + chain?: string + subchain?: string +} + +export class EthosIdentityProvider { + static async getWalletScore( + pubkey: string, + walletAddress: string, + options: EthosImportOptions = {}, + ): Promise { + const chain = options.chain || "evm" + const subchain = options.subchain || "mainnet" + const normalizedWallet = this.normalizeAddress(walletAddress, chain) + + const account = await ensureGCRForUser(pubkey) + + this.assertWalletLinked(account, chain, subchain, normalizedWallet) + + const existing = this.getExistingIdentity( + account, + chain, + subchain, + normalizedWallet, + ) + + if (existing) { + return existing + } + + const apiClient = EthosApiClient.getInstance() + const payload = await apiClient.getScore(normalizedWallet) + + return { + address: normalizedWallet, + score: payload.score, + profileId: payload.profileId, + lastSyncedAt: new Date().toISOString(), + metadata: { + displayName: payload.displayName, + username: payload.username, + }, + } + } + + static async listIdentities( + pubkey: string, + ): Promise { + const account = await ensureGCRForUser(pubkey) + return this.flattenIdentities(account) + } + + private static assertWalletLinked( + account: GCRMain, + chain: string, + subchain: string, + walletAddress: string, + ) { + const normalizedWallet = this.normalizeAddress(walletAddress, chain) + const linked = + account.identities?.xm?.[chain]?.[subchain]?.some(identity => { + const stored = this.normalizeAddress(identity.address, chain) + return stored === normalizedWallet + }) || false + + if (!linked) { + throw new Error("Wallet is not linked to this account") + } + } + + private static flattenIdentities( + account: GCRMain, + ): EthosIdentitySummary[] { + const summaries: EthosIdentitySummary[] = [] + const ethosIdentities = account.identities?.ethos || {} + + Object.entries(ethosIdentities).forEach(([chain, subchains]) => { + Object.entries(subchains).forEach(([subchain, identities]) => { + identities.forEach(identity => { + summaries.push({ + ...identity, + chain, + subchain, + }) + }) + }) + }) + + return summaries + } + + private static normalizeAddress(address: string, chain: string): string { + if (!address) { + throw new Error("Wallet address is required") + } + + if (chain === "evm") { + return address.trim().toLowerCase() + } + + return address.trim() + } + + private static getExistingIdentity( + account: GCRMain, + chain: string, + subchain: string, + walletAddress: string, + ): SavedEthosIdentity | undefined { + const ethosIdentities = account.identities?.ethos || {} + const normalizedWallet = this.normalizeAddress(walletAddress, chain) + return ethosIdentities?.[chain]?.[subchain]?.find(identity => { + const storedAddress = this.normalizeAddress(identity.address, chain) + return storedAddress === normalizedWallet + }) + } +} diff --git a/src/libs/identity/tools/ethos.ts b/src/libs/identity/tools/ethos.ts new file mode 100644 index 000000000..fc6a37fb6 --- /dev/null +++ b/src/libs/identity/tools/ethos.ts @@ -0,0 +1,99 @@ +import axios, { AxiosInstance } from "axios" +import log from "@/utilities/logger" + +export interface EthosScorePayload { + score: number + profileId?: number + displayName?: string + username?: string +} + +interface EthosScoreResponse { + score: number +} + +interface EthosProfileResponse { + id: number + profileId: number + displayName?: string + username?: string + score: number + status: string + avatarUrl?: string +} + +const BASE_URL = "https://api.ethos.network/api/v2" + +export class EthosApiClient { + private static instance: EthosApiClient + private readonly http: AxiosInstance + + private constructor() { + this.http = axios.create({ + baseURL: BASE_URL, + timeout: 10_000, + headers: { + Accept: "application/json", + }, + }) + } + + static getInstance(): EthosApiClient { + if (!EthosApiClient.instance) { + EthosApiClient.instance = new EthosApiClient() + } + + return EthosApiClient.instance + } + + async getScore(address: string): Promise { + if (!address) { + throw new Error("Wallet address is required to fetch Ethos score") + } + + const normalized = address.trim().toLowerCase() + + try { + const userResponse = await this.http.get<{ + id: number + profileId: number | null + displayName?: string + username?: string + score: number + status: string + }>(`/user/by/address/${normalized}`) + + const score = userResponse.data?.score + if (score === undefined || score === null) { + throw new Error("Ethos API returned no score data") + } + + const result: EthosScorePayload = { + score, + profileId: userResponse.data.profileId ?? undefined, + displayName: userResponse.data.displayName, + username: userResponse.data.username ?? undefined, + } + + return result + } catch (error: any) { + // Check if it's a 404 - wallet has no Ethos profile + if (error?.response?.status === 404) { + throw new Error( + "This wallet does not have an Ethos profile. Please create one at ethos.network first.", + ) + } + + const statusCode = error?.response?.status ?? "unknown" + const errorType = error?.code === "ECONNREFUSED" + ? "connection_refused" + : error?.code === "ETIMEDOUT" + ? "timeout" + : "api_error" + log.error( + `[EthosApiClient] API request failed: status=${statusCode}, type=${errorType}`, + ) + throw new Error("Failed to fetch Ethos score") + } + } +} diff --git a/src/libs/network/manageGCRRoutines.ts b/src/libs/network/manageGCRRoutines.ts index f2246e531..04315c6b3 100644 --- a/src/libs/network/manageGCRRoutines.ts +++ b/src/libs/network/manageGCRRoutines.ts @@ -7,6 +7,7 @@ import ensureGCRForUser from "../blockchain/gcr/gcr_routines/ensureGCRForUser" import { Referrals } from "@/features/incentive/referrals" import GCR from "../blockchain/gcr/gcr" import { NomisIdentityProvider } from "@/libs/identity/providers/nomisIdentityProvider" +import { EthosIdentityProvider } from "@/libs/identity/providers/ethosIdentityProvider" import { BroadcastManager } from "../communications/broadcastManager" interface GCRRoutinePayload { @@ -146,6 +147,60 @@ export default async function manageGCRRoutines( break } + case "getEthosScore": { + const options = params[0] + + if (!options?.walletAddress) { + response.result = 400 + response.response = null + response.extra = { error: "walletAddress is required" } + break + } + + try { + response.response = await EthosIdentityProvider.getWalletScore( + sender, + options.walletAddress, + { + chain: options.chain, + subchain: options.subchain, + }, + ) + } catch (error) { + response.result = 400 + response.response = null + const errorMsg = error instanceof Error ? error.message : "" + // Whitelist of safe user-facing Ethos error messages + const safeEthosErrors = [ + "Wallet is not linked to this account", + "Wallet address is required", + "This wallet does not have an Ethos profile", + "Failed to fetch Ethos score", + "Ethos API returned no score data", + ] + const isSafeError = safeEthosErrors.some(safe => errorMsg.includes(safe)) + response.extra = { + error: isSafeError ? errorMsg : "Failed to fetch Ethos score", + } + } + break + } + + case "getEthosIdentities": { + try { + response.response = await EthosIdentityProvider.listIdentities( + sender, + ) + } catch (error) { + response.result = 400 + response.response = null + response.extra = { + error: "Failed to fetch Ethos identities", + } + } + break + } + case "syncNewBlock": { response.response = await BroadcastManager.handleNewBlock( sender, diff --git a/src/libs/network/routines/transactions/handleIdentityRequest.ts b/src/libs/network/routines/transactions/handleIdentityRequest.ts index df9670888..3b490689f 100644 --- a/src/libs/network/routines/transactions/handleIdentityRequest.ts +++ b/src/libs/network/routines/transactions/handleIdentityRequest.ts @@ -9,7 +9,7 @@ import { Transaction } from "@kynesyslabs/demosdk/types" import { PqcIdentityAssignPayload } from "@kynesyslabs/demosdk/abstraction" import IdentityManager from "@/libs/blockchain/gcr/gcr_routines/identityManager" import { UDIdentityManager } from "@/libs/blockchain/gcr/gcr_routines/udIdentityManager" -import { NomisWalletIdentity } from "@/model/entities/types/IdentityTypes" +import { NomisWalletIdentity, EthosWalletIdentity } from "@/model/entities/types/IdentityTypes" import { Referrals } from "@/features/incentive/referrals" import log from "@/utilities/logger" import ensureGCRForUser from "@/libs/blockchain/gcr/gcr_routines/ensureGCRForUser" @@ -100,10 +100,15 @@ export default async function handleIdentityRequest( return await IdentityManager.verifyNomisPayload( payload.payload as NomisWalletIdentity, ) + case "ethos_identity_assign": + return await IdentityManager.verifyEthosPayload( + payload.payload as EthosWalletIdentity, + ) case "xm_identity_remove": case "pqc_identity_remove": case "web2_identity_remove": case "nomis_identity_remove": + case "ethos_identity_remove": case "ud_identity_remove": return { success: true, diff --git a/src/libs/omniprotocol/protocol/handlers/gcr.ts b/src/libs/omniprotocol/protocol/handlers/gcr.ts index 698cc4d63..276a70df9 100644 --- a/src/libs/omniprotocol/protocol/handlers/gcr.ts +++ b/src/libs/omniprotocol/protocol/handlers/gcr.ts @@ -34,7 +34,7 @@ interface IdentityAssignRequest { type: "identity" isRollback: boolean account: string - context: "xm" | "web2" | "pqc" | "ud" + context: "xm" | "web2" | "pqc" | "ud" | "ethos" operation: "add" | "remove" data: any // Varies by context - see GCREditIdentity txhash: string @@ -46,7 +46,7 @@ interface IdentityAssignRequest { * Handler for 0x41 GCR_IDENTITY_ASSIGN opcode * * Internal operation triggered by write transactions to assign/remove identities. - * Uses GCRIdentityRoutines to apply identity changes (xm, web2, pqc, ud). + * Uses GCRIdentityRoutines to apply identity changes (xm, web2, pqc, ud, ethos). */ export const handleIdentityAssign: OmniHandler = async ({ message, context }) => { if (!message.payload || !Buffer.isBuffer(message.payload) || message.payload.length === 0) { @@ -71,8 +71,8 @@ export const handleIdentityAssign: OmniHandler = async ({ message, conte return encodeResponse(errorResponse(400, "account is required")) } - if (!editOperation.context || !["xm", "web2", "pqc", "ud"].includes(editOperation.context)) { - return encodeResponse(errorResponse(400, "Invalid context, must be xm, web2, pqc, or ud")) + if (!editOperation.context || !["xm", "web2", "pqc", "ud", "ethos"].includes(editOperation.context)) { + return encodeResponse(errorResponse(400, "Invalid context, must be xm, web2, pqc, or ethos")) } if (!editOperation.operation || !["add", "remove"].includes(editOperation.operation)) { diff --git a/src/model/entities/GCRv2/GCR_Main.ts b/src/model/entities/GCRv2/GCR_Main.ts index d3154f288..15e40a09d 100644 --- a/src/model/entities/GCRv2/GCR_Main.ts +++ b/src/model/entities/GCRv2/GCR_Main.ts @@ -41,6 +41,7 @@ export class GCRMain { points: number }> nomisScores: { [chain: string]: number } + ethosScores?: { [chain: string]: number } } lastUpdated: Date } diff --git a/src/model/entities/types/IdentityTypes.ts b/src/model/entities/types/IdentityTypes.ts index b13ae73d4..01c1ecf65 100644 --- a/src/model/entities/types/IdentityTypes.ts +++ b/src/model/entities/types/IdentityTypes.ts @@ -44,6 +44,42 @@ export interface SavedNomisIdentity { } } +/** + * Ethos wallet identity structure for linking Ethos reputation scores + * @property chain - Blockchain network (e.g., "evm") + * @property subchain - Network subchain (e.g., "mainnet") + * @property address - Wallet address + * @property score - Ethos reputation score (0-2800) + * @property profileId - Ethos profile ID + * @property lastSyncedAt - ISO timestamp of last sync + * @property metadata - Additional profile data (displayName, username) + */ +export interface EthosWalletIdentity { + chain: string + subchain: string + address: string + score: number + profileId?: number + lastSyncedAt: string + metadata?: { + displayName?: string + username?: string + [key: string]: unknown + } +} + +export interface SavedEthosIdentity { + address: string + score: number + profileId?: number + lastSyncedAt: string + metadata?: { + displayName?: string + username?: string + [key: string]: unknown + } +} + /** * The PQC identity saved in the GCR */ @@ -105,4 +141,9 @@ export type StoredIdentities = { [subchain: string]: SavedNomisIdentity[] } } + ethos?: { + [chain: string]: { + [subchain: string]: SavedEthosIdentity[] + } + } } diff --git a/src/types/nomis-augmentations.d.ts b/src/types/nomis-augmentations.d.ts index a1cb88da7..75c5efe68 100644 --- a/src/types/nomis-augmentations.d.ts +++ b/src/types/nomis-augmentations.d.ts @@ -19,8 +19,22 @@ declare module "@kynesyslabs/demosdk/build/types/blockchain/GCREdit" { metadata?: Record } + export interface EthosIdentityGCREditData { + chain: string + subchain: string + address: string + score: number + profileId?: number + lastSyncedAt: string + metadata?: { + displayName?: string + username?: string + [key: string]: unknown + } + } + export interface GCREditIdentity { - context: "xm" | "web2" | "pqc" | "ud" | "nomis" + context: "xm" | "web2" | "pqc" | "ud" | "nomis" | "ethos" data: | Web2GCRData | XmGCRIdentityData @@ -29,5 +43,6 @@ declare module "@kynesyslabs/demosdk/build/types/blockchain/GCREdit" { | PqcIdentityRemovePayload["payload"] | UdGCRData | NomisIdentityGCREditData + | EthosIdentityGCREditData } }