import * as R from "ramda"; import { IIdentifier } from "@veramo/core"; import { accessToken, SimpleSigner } from "@/libs/crypto"; import * as didJwt from "did-jwt"; import { Axios, AxiosResponse } from "axios"; import { Contact } from "@/db/tables/contacts"; export const SCHEMA_ORG_CONTEXT = "https://schema.org"; // the object in RegisterAction claims export const SERVICE_ID = "endorser.ch"; // the prefix for the contact URL export const CONTACT_URL_PREFIX = "https://endorser.ch"; // the suffix for the contact URL export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt="; // the prefix for handle IDs, the permanent ID for claims on Endorser export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/"; export interface AgreeVerifiableCredential { "@context": string; "@type": string; // "any" because arbitrary objects can be subject of agreement // eslint-disable-next-line @typescript-eslint/no-explicit-any object: Record; } export interface GiverInputInfo { did?: string; name?: string; } export interface GiverOutputInfo { action: string; giver?: GiverInputInfo; description?: string; hours?: number; } export interface ClaimResult { success: { claimId: string; handleId: string }; error: { code: string; message: string }; } export interface GenericVerifiableCredential { "@context": string; "@type": string; } export interface GenericServerRecord extends GenericVerifiableCredential { handleId?: string; id?: string; issuedAt?: string; issuer?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any claim: Record; claimType?: string; } export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = { "@context": SCHEMA_ORG_CONTEXT, "@type": "", claim: {}, }; export interface GiveServerRecord { agentDid: string; amount: number; amountConfirmed: number; description: string; fullClaim: GiveVerifiableCredential; handleId: string; issuedAt: string; recipientDid: string; unit: string; } export interface OfferServerRecord { amount: number; amountGiven: number; offeredByDid: string; recipientDid: string; requirementsMet: boolean; unit: string; validThrough: string; } // Note that previous VCs may have additional fields. // https://endorser.ch/doc/html/transactions.html#id4 export interface GiveVerifiableCredential { "@context"?: string; // optional when embedded, eg. in an Agree "@type": "GiveAction"; agent?: { identifier: string }; description?: string; fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[]; identifier?: string; object?: { amountOfThisGood: number; unitCode: string }; recipient?: { identifier: string }; } // Note that previous VCs may have additional fields. // https://endorser.ch/doc/html/transactions.html#id8 export interface OfferVerifiableCredential { "@context"?: string; // optional when embedded, eg. in an Agree "@type": "Offer"; description?: string; includesObject?: { amountOfThisGood: number; unitCode: string }; itemOffered?: { description?: string; isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string }; }; offeredBy?: { identifier: string }; validThrough?: string; } // Note that previous VCs may have additional fields. // https://endorser.ch/doc/html/transactions.html#id7 export interface PlanVerifiableCredential { "@context": "https://schema.org"; "@type": "PlanAction"; name: string; description: string; identifier?: string; location?: { geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number }; }; } export interface PlanServerRecord { agentDid?: string; // optional, if the issuer wants someone else to manage as well description: string; endTime?: string; issuerDid: string; handleId: string; locLat?: number; locLon?: number; startTime?: string; url?: string; } export interface RegisterVerifiableCredential { "@context": string; "@type": string; agent: { identifier: string }; object: string; participant: { identifier: string }; } export interface InternalError { error: string; // for system logging userMessage?: string; // for user display } // This is used to check for hidden info. // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 const HIDDEN_DID = "did:none:HIDDEN"; export function isHiddenDid(did: string) { return did === HIDDEN_DID; } /** * @return true for any nested string where func(input) === true * * Similar logic is found in endorser-mobile. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function testRecursivelyOnString(func: (arg0: any) => boolean, input: any) { if (Object.prototype.toString.call(input) === "[object String]") { return func(input); } else if (input instanceof Object) { if (!Array.isArray(input)) { // it's an object for (const key in input) { if (testRecursivelyOnString(func, input[key])) { return true; } } } else { // it's an array for (const value of input) { if (testRecursivelyOnString(func, value)) { return true; } } } return false; } else { return false; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function containsHiddenDid(obj: any) { return testRecursivelyOnString(isHiddenDid, obj); } export function stripEndorserPrefix(claimId: string) { if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) { return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length); } else { return claimId; } } // similar logic is found in endorser-mobile // eslint-disable-next-line @typescript-eslint/no-explicit-any export function removeSchemaContext(obj: any) { return obj["@context"] === SCHEMA_ORG_CONTEXT ? R.omit(["@context"], obj) : obj; } // similar logic is found in endorser-mobile export function addLastClaimOrHandleAsIdIfMissing( // eslint-disable-next-line @typescript-eslint/no-explicit-any obj: any, lastClaimId?: string, handleId?: string, ) { if (!obj.identifier && lastClaimId) { const result = R.clone(obj); result.lastClaimId = lastClaimId; return result; } else if (!obj.identifier && handleId) { const result = R.clone(obj); result.identifier = handleId; return result; } else { return obj; } } // return clone of object without any nested *VisibleToDids keys // similar logic is found in endorser-mobile // eslint-disable-next-line @typescript-eslint/no-explicit-any export function removeVisibleToDids(input: any): any { if (input instanceof Object) { if (!Array.isArray(input)) { // it's an object // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: Record = {}; for (const key in input) { if (!key.endsWith("VisibleToDids")) { // eslint-disable-next-line @typescript-eslint/no-explicit-any result[key] = removeVisibleToDids(R.clone(input[key])); } } return result; } else { // it's an array return R.map(removeVisibleToDids, input); } return false; } else { return input; } } /** always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY Similar logic is found in endorser-mobile. **/ export function didInfo( did: string | undefined, activeDid: string | undefined, allMyDids: string[], contacts: Contact[], ): string { if (!did) return "Someone Anonymous"; const myId = R.find(R.equals(did), allMyDids); if (myId) return `You${myId !== activeDid ? " (Alt ID)" : ""}`; const contact = R.find((c) => c.did === did, contacts); return contact ? contact.name || "Contact With No Name" : isHiddenDid(did) ? "Someone Not In Network" : "Someone Not In Contacts"; } export interface ResultWithType { type: string; } export interface SuccessResult extends ResultWithType { type: "success"; response: AxiosResponse; } export interface ErrorResult { type: "error"; error: InternalError; } export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult; /** * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * * @param identity * @param fromDid may be null * @param toDid * @param description may be null; should have this or hours * @param hours may be null; should have this or description */ export async function createAndSubmitGive( axios: Axios, apiServer: string, identity: IIdentifier, fromDid?: string, toDid?: string, description?: string, hours?: number, unitCode?: string, fulfillsProjectHandleId?: string, fulfillsOfferHandleId?: string, isTrade: boolean = false, ): Promise { const vcClaim: GiveVerifiableCredential = { "@context": "https://schema.org", "@type": "GiveAction", recipient: toDid ? { identifier: toDid } : undefined, agent: fromDid ? { identifier: fromDid } : undefined, description: description || undefined, object: hours ? { amountOfThisGood: hours, unitCode: unitCode || "HUR" } : undefined, fulfills: [{ "@type": isTrade ? "TradeAction" : "DonateAction" }], }; if (fulfillsProjectHandleId) { vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this vcClaim.fulfills.push({ "@type": "PlanAction", identifier: fulfillsProjectHandleId, }); } if (fulfillsOfferHandleId) { vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this vcClaim.fulfills.push({ "@type": "Offer", identifier: fulfillsOfferHandleId, }); } return createAndSubmitClaim( vcClaim as GenericServerRecord, identity, apiServer, axios, ); } /** * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * * @param identity * @param description may be null; should have this or hours * @param hours may be null; should have this or description * @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null) * @param fulfillsProjectHandleId ID of project to which this contributes (may be null) */ export async function createAndSubmitOffer( axios: Axios, apiServer: string, identity: IIdentifier, description?: string, hours?: number, expirationDate?: string, fulfillsProjectHandleId?: string, ): Promise { const vcClaim: OfferVerifiableCredential = { "@context": "https://schema.org", "@type": "Offer", offeredBy: { identifier: identity.did }, validThrough: expirationDate || undefined, }; if (hours) { vcClaim.includesObject = { amountOfThisGood: hours, unitCode: "HUR", }; } if (description) { vcClaim.itemOffered = { description }; } if (fulfillsProjectHandleId) { vcClaim.itemOffered = vcClaim.itemOffered || {}; vcClaim.itemOffered.isPartOf = { "@type": "PlanAction", identifier: fulfillsProjectHandleId, }; } return createAndSubmitClaim( vcClaim as GenericServerRecord, identity, apiServer, axios, ); } export async function createAndSubmitClaim( vcClaim: GenericVerifiableCredential, identity: IIdentifier, apiServer: string, axios: Axios, ): Promise { try { const vcPayload = { vc: { "@context": ["https://www.w3.org/2018/credentials/v1"], type: ["VerifiableCredential"], credentialSubject: vcClaim, }, }; // Create a signature using private key of identity const firstKey = identity.keys[0]; const privateKeyHex = firstKey?.privateKeyHex; if (!privateKeyHex) { throw { error: "No private key", message: `Your identifier ${identity.did} is not configured correctly. Use a different identifier.`, }; } const signer = await SimpleSigner(privateKeyHex); // Create a JWT for the request const vcJwt: string = await didJwt.createJWT(vcPayload, { issuer: identity.did, signer, }); // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = `${apiServer}/api/v2/claim`; const token = await accessToken(identity); const response = await axios.post(url, payload, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, }); return { type: "success", response }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { console.error("Error creating claim:", error); const errorMessage: string = error.response?.data?.error?.message || error.message || "Unknown error"; return { type: "error", error: { error: errorMessage, userMessage: "Failed to create and submit the claim.", }, }; } } // from https://stackoverflow.com/a/175787/845494 // export function isNumeric(str: string): boolean { return !isNaN(+str); } export function numberOrZero(str: string): number { return isNumeric(str) ? +str : 0; } export interface ErrorResponse { error?: { message?: string; }; } export interface RateLimits { doneClaimsThisWeek: string; doneRegistrationsThisMonth: string; maxClaimsPerWeek: string; maxRegistrationsPerMonth: string; nextMonthBeginDateTime: string; nextWeekBeginDateTime: string; } /** * Represents data about a project **/ export interface ProjectData { /** * Name of the project **/ name: string; /** * Description of the project **/ description: string; /** * URL referencing information about the project **/ handleId: string; /** * The DID of the issuer */ issuerDid: string; /** * The Identier of the project **/ rowid: string; } export interface VerifiableCredential { "@context": string; "@type": string; name: string; description: string; identifier?: string; } export interface WorldProperties { startTime?: string; endTime?: string; }