import { Axios , AxiosRequestConfig , AxiosResponse } from "axios" ;
import { Buffer } from "buffer" ;
import { sha256 } from "ethereum-cryptography/sha256" ;
import { LRUCache } from "lru-cache" ;
import * as R from "ramda" ;
import {
APP_SERVER ,
DEFAULT_IMAGE_API_SERVER ,
NotificationIface ,
} from "@/constants/app" ;
import { Contact } from "@/db/tables/contacts" ;
import { accessToken , deriveAddress , nextDerivationPath } from "@/libs/crypto" ;
import { logConsoleAndDb , NonsensitiveDexie } from "@/db/index" ;
import {
retrieveAccountMetadata ,
retrieveFullyDecryptedAccount ,
getPasskeyExpirationSeconds ,
GiverReceiverInputInfo ,
} from "@/libs/util" ;
import { createEndorserJwtForKey , KeyMeta } from "@/libs/crypto/vc" ;
import { Account } from "@/db/tables/accounts" ;
export const SCHEMA_ORG_CONTEXT = "https://schema.org" ;
// the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch" ;
// the header line for contacts exported via Endorser Mobile
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered" ;
// the suffix for the contact URL in this app where they are confirmed before import
export const CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact-import/" ;
// the suffix for the contact URL in this app where a single one gets imported automatically
export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=" ;
// the suffix for the old contact URL -- deprecated Jan 2025, though "endorser.ch/contact?jwt=" shows data on endorser.ch server
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=" ;
// unused now that we match on the URL path; just note that it was used for a while to create URLs that showed at endorser.ch
//export const CONTACT_URL_PREFIX_ENDORSER_CH_OLD = "https://endorser.ch";
// 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 < string , any > ;
}
export interface GiverOutputInfo {
action : string ;
giver? : GiverReceiverInputInfo ;
description? : string ;
amount? : number ;
unitCode? : string ;
}
export interface ClaimResult {
success : { claimId : string ; handleId : string } ;
error : { code : string ; message : string } ;
}
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
"@context" ? : string ; // optional when embedded, eg. in an Agree
"@type" : string ;
[ key : string ] : any ; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface GenericCredWrapper < T extends GenericVerifiableCredential > {
claim : T ;
claimType? : string ;
handleId : string ;
id : string ;
issuedAt : string ;
issuer : string ;
publicUrls? : Record < string , string > ; // only for IDs that want to be public
}
export const BLANK_GENERIC_SERVER_RECORD : GenericCredWrapper < GenericVerifiableCredential > =
{
claim : { "@type" : "" } ,
handleId : "" ,
id : "" ,
issuedAt : "" ,
issuer : "" ,
} ;
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
agentDid : string ;
amount : number ;
amountConfirmed : number ;
description : string ;
fullClaim : GiveVerifiableCredential ;
fulfillsHandleId : string ;
fulfillsPlanHandleId? : string ;
fulfillsType? : string ;
handleId : string ;
issuedAt : string ;
issuerDid : string ;
jwtId : string ;
providerPlanHandleId? : string ;
recipientDid : string ;
unit : string ;
}
// a summary record; the VC is found the fullClaim field
export interface OfferSummaryRecord {
amount : number ;
amountGiven : number ;
amountGivenConfirmed : number ;
fullClaim : OfferVerifiableCredential ;
fulfillsPlanHandleId : string ;
handleId : string ;
issuerDid : string ;
jwtId : string ;
nonAmountGivenConfirmed : number ;
objectDescription : string ;
offeredByDid : string ;
recipientDid : string ;
requirementsMet : boolean ;
unit : string ;
validThrough : string ;
}
export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName : string ;
}
// a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord {
agentDid? : string ; // optional, if the issuer wants someone else to manage as well
description : string ;
endTime? : string ;
fulfillsPlanHandleId : string ;
handleId : string ;
image? : string ;
issuerDid : string ;
locLat? : number ;
locLon? : number ;
name? : string ;
startTime? : string ;
url? : string ;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@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 ;
image? : string ;
object ? : { amountOfThisGood : number ; unitCode : string } ;
provider? : GenericVerifiableCredential ; // typically @type & identifier
recipient ? : { identifier : string } ;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context" ? : string ; // optional when embedded... though it doesn't make sense to agree to an offer
"@type" : "Offer" ;
description? : string ; // conditions for the offer
includesObject ? : { amountOfThisGood : number ; unitCode : string } ;
itemOffered ? : {
description? : string ; // description of the item
isPartOf ? : { identifier? : string ; lastClaimId? : string ; "@type" ? : string } ;
} ;
offeredBy ? : { identifier : string } ;
recipient ? : { identifier : string } ;
validThrough? : string ;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context" : "https://schema.org" ;
"@type" : "PlanAction" ;
name : string ;
agent ? : { identifier : string } ;
description? : string ;
identifier? : string ;
lastClaimId? : string ;
location ? : {
geo : { "@type" : "GeoCoordinates" ; latitude : number ; longitude : number } ;
} ;
}
/ * *
* Represents data about a project
*
* @deprecated
* We should use PlanSummaryRecord instead .
* * /
export interface PlanData {
/ * *
* Name of the project
* * /
name : string ;
/ * *
* Description of the project
* * /
description : string ;
/ * *
* URL referencing information about the project
* * /
handleId : string ;
image? : string ;
/ * *
* The DID of the issuer
* /
issuerDid : string ;
/ * *
* The identifier of the project -- different from jwtId , needs to be fixed
* * /
rowid? : string ;
}
export interface EndorserRateLimits {
doneClaimsThisWeek : string ;
doneRegistrationsThisMonth : string ;
maxClaimsPerWeek : string ;
maxRegistrationsPerMonth : string ;
nextMonthBeginDateTime : string ;
nextWeekBeginDateTime : string ;
}
export interface ImageRateLimits {
doneImagesThisWeek : string ;
maxImagesPerWeek : string ;
nextWeekBeginDateTime : string ;
}
export interface VerifiableCredential {
exp? : number ;
iat : number ;
iss : string ;
vc : {
"@context" : string [ ] ;
type : string [ ] ;
credentialSubject : VerifiableCredentialSubject ;
} ;
}
// similar to GenericVerifiableCredential... maybe replace that one
export interface VerifiableCredentialSubject {
"@context" : string ;
"@type" : string ;
[ key : string ] : any ; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface WorldProperties {
startTime? : string ;
endTime? : string ;
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
"@context" : typeof SCHEMA_ORG_CONTEXT ;
"@type" : "RegisterAction" ;
agent : { identifier : string } ;
identifier? : string ; // used for invites (when participant ID isn't known)
object : string ;
participant ? : { identifier : string } ; // used when person is known (not an invite)
}
// now for some of the error & other wrapper types
export interface ResultWithType {
type : string ;
}
export interface SuccessResult extends ResultWithType {
type : "success" ;
response : AxiosResponse < ClaimResult > ;
}
export interface ErrorResponse {
error ? : {
message? : string ;
} ;
}
export interface InternalError {
error : string ; // for system logging
userMessage? : string ; // for user display
}
export interface ErrorResult extends ResultWithType {
type : "error" ;
error : InternalError ;
}
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult ;
/ * *
* This is similar to Contact but it grew up in different logic paths .
* We may want to change this to be a Contact .
* /
export interface UserInfo {
did : string ;
name : string ;
publicEncKey : string ;
registered : boolean ;
profileImageUrl? : string ;
nextPublicEncKeyHash? : string ;
}
// 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 isDid ( did : string ) {
return did . startsWith ( "did:" ) ;
}
export function isHiddenDid ( did : string ) {
return did === HIDDEN_DID ;
}
export function isEmptyOrHiddenDid ( did? : string ) {
return ! did || did === HIDDEN_DID ; // catching empty string as well
}
/ * *
* @return true for any string within this primitive / object / array where func ( input ) === true
*
* Similar logic is found in endorser - mobile .
* /
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function testRecursivelyOnStrings ( 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 ( testRecursivelyOnStrings ( func , input [ key ] ) ) {
return true ;
}
}
} else {
// it's an array
for ( const value of input ) {
if ( testRecursivelyOnStrings ( func , value ) ) {
return true ;
}
}
}
return false ;
} else {
return false ;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function containsHiddenDid ( obj : any ) {
return testRecursivelyOnStrings ( isHiddenDid , obj ) ;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const containsNonHiddenDid = ( obj : any ) = > {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return testRecursivelyOnStrings ( ( s : any ) = > isDid ( s ) && ! isHiddenDid ( s ) , 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 code is also contained 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 < string , any > = { } ;
for ( const key in input ) {
if ( ! key . endsWith ( "VisibleToDids" ) ) {
result [ key ] = removeVisibleToDids ( R . clone ( input [ key ] ) ) ;
}
}
return result ;
} else {
// it's an array
return R . map ( removeVisibleToDids , input ) ;
}
} else {
return input ;
}
}
export function contactForDid (
did : string | undefined ,
contacts : Contact [ ] ,
) : Contact | undefined {
return isEmptyOrHiddenDid ( did )
? undefined
: R . find ( ( c ) = > c . did === did , contacts ) ;
}
/ * *
*
* Similar logic is found in endorser - mobile .
*
* @param did
* @param activeDid
* @param contact
* @param allMyDids
* @return { known : boolean , displayName : string , profileImageUrl? : string }
* where 'known' is true if they are in the contacts
* /
export function didInfoForContact (
did : string | undefined ,
activeDid : string | undefined ,
contact? : Contact ,
allMyDids : string [ ] = [ ] ,
showDidForVisible : boolean = false ,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) : { known : boolean ; displayName : string ; profileImageUrl? : string } {
if ( ! did ) return { displayName : "Someone Unnamed/Unknown" , known : false } ;
if ( did === activeDid ) {
return { displayName : "You" , known : true } ;
} else if ( contact ) {
return {
displayName : contact.name || "Contact With No Name" ,
known : ! ! contact ,
profileImageUrl : contact.profileImageUrl ,
} ;
} else {
const myId = R . find ( R . equals ( did ) , allMyDids ) ;
return myId
? { displayName : "You (Alt ID)" , known : true }
: isHiddenDid ( did )
? { displayName : "Someone Outside Your View" , known : false }
: {
displayName : showDidForVisible
? did
: "Someone Visible But Not In Your Contact List" ,
known : false ,
} ;
}
}
/ * *
always returns text , maybe something like "unnamed" or "unknown"
Now that we ' re using more informational didInfoForContact under the covers , we might want to consolidate .
* * /
export function didInfo (
did : string | undefined ,
activeDid : string | undefined ,
allMyDids : string [ ] ,
contacts : Contact [ ] ,
) : string {
const contact = contactForDid ( did , contacts ) ;
return didInfoForContact ( did , activeDid , contact , allMyDids ) . displayName ;
}
/ * *
* return text description without any references to "you" as user
* /
export function didInfoForCertificate (
did : string | undefined ,
contacts : Contact [ ] ,
) : string {
return didInfoForContact (
did ,
undefined ,
contactForDid ( did , contacts ) ,
[ ] ,
true ,
) . displayName ;
}
let passkeyAccessToken : string = "" ;
let passkeyTokenExpirationEpochSeconds : number = 0 ;
export function clearPasskeyToken() {
passkeyAccessToken = "" ;
passkeyTokenExpirationEpochSeconds = 0 ;
}
export function tokenExpiryTimeDescription() {
if (
! passkeyAccessToken ||
passkeyTokenExpirationEpochSeconds < new Date ( ) . getTime ( ) / 1000
) {
return "Token has expired" ;
} else {
return (
"Token expires at " +
new Date ( passkeyTokenExpirationEpochSeconds * 1000 ) . toLocaleString ( )
) ;
}
}
/ * *
* Get the headers for a request , potentially including Authorization
* /
export async function getHeaders (
did? : string ,
$notify ? : ( notification : NotificationIface , timeout? : number ) = > void ,
failureMessage? : string ,
) {
const headers : { "Content-Type" : string ; Authorization? : string } = {
"Content-Type" : "application/json" ,
} ;
if ( did ) {
try {
let token ;
const account = await retrieveAccountMetadata ( did ) ;
if ( account ? . passkeyCredIdHex ) {
if (
passkeyAccessToken &&
passkeyTokenExpirationEpochSeconds > Date . now ( ) / 1000
) {
// there's an active current passkey token
token = passkeyAccessToken ;
} else {
// there's no current passkey token or it's expired
token = await accessToken ( did ) ;
passkeyAccessToken = token ;
const passkeyExpirationSeconds = await getPasskeyExpirationSeconds ( ) ;
passkeyTokenExpirationEpochSeconds =
Date . now ( ) / 1000 + passkeyExpirationSeconds ;
}
} else {
token = await accessToken ( did ) ;
}
headers [ "Authorization" ] = "Bearer " + token ;
} catch ( error ) {
// This rarely happens: we've seen it when they have account info but the
// encryption secret got lost. But in most cases we want users to at
// least see their feed -- and anything else that returns results for
// anonymous users.
// We'll continue with an anonymous request... still want to show feed and other things, but ideally let them know.
logConsoleAndDb (
"Something failed in getHeaders call (will proceed anonymously" +
( $notify ? " and notify user" : "" ) +
"): " +
// IntelliJ type system complains about getCircularReplacer() with: Argument of type '(obj: any, key: string, value: any) => any' is not assignable to parameter of type '(this: any, key: string, value: any) => any'.
//JSON.stringify(error, getCircularReplacer()), // JSON.stringify(error) on a Dexie error throws another error about: Converting circular structure to JSON
error ,
true ,
) ;
if ( $notify ) {
// remember: only want to do this if they supplied a DID, expecting personal results
const notifyMessage =
failureMessage ||
"Showing anonymous data. See the Help page for help with personal data." ;
$notify (
{
group : "alert" ,
type : "danger" ,
title : "Personal Data Error" ,
text : notifyMessage ,
} ,
3000 ,
) ;
}
}
} else {
// it's usually OK to request without auth; we assume we're only here when allowed
}
return headers ;
}
const planCache : LRUCache < string , PlanSummaryRecord > = new LRUCache ( {
max : 500 ,
} ) ;
/ * *
* Helpful for server errors , to get all the info -- including stuff skipped by toString & JSON . stringify
*
* @param error
* /
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function errorStringForLog ( error : any ) {
let stringifiedError = "" + error ;
try {
stringifiedError = JSON . stringify ( error ) ;
} catch ( e ) {
// can happen with Dexie, eg:
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'DexieError2'
// | property '_promise' -> object with constructor 'DexiePromise'
// --- property '_value' closes the circle
}
let fullError = "" + error + " - JSON: " + stringifiedError ;
const errorResponseText = JSON . stringify ( error . response ) ;
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if ( ! R . empty ( errorResponseText ) && ! fullError . includes ( errorResponseText ) ) {
// add error.response stuff
if ( R . equals ( error ? . config , error ? . response ? . config ) ) {
// but exclude "config" because it's already in there
const newErrorResponseText = JSON . stringify (
R . omit ( [ "config" ] as never [ ] , error . response ) ,
) ;
fullError += " - .response w/o same config JSON: " + newErrorResponseText ;
} else {
fullError += " - .response JSON: " + errorResponseText ;
}
}
return fullError ;
}
/ * *
* @param handleId nullable , in which case "undefined" will be returned
* @param requesterDid optional , in which case no private info will be returned
* @param axios
* @param apiServer
* /
export async function getPlanFromCache (
handleId : string | undefined ,
axios : Axios ,
apiServer : string ,
requesterDid? : string ,
) : Promise < PlanSummaryRecord | undefined > {
if ( ! handleId ) {
return undefined ;
}
let cred = planCache . get ( handleId ) ;
if ( ! cred ) {
const url =
apiServer +
"/api/v2/report/plans?handleId=" +
encodeURIComponent ( handleId ) ;
const headers = await getHeaders ( requesterDid ) ;
try {
const resp = await axios . get ( url , { headers } ) ;
if ( resp . status === 200 && resp . data ? . data ? . length > 0 ) {
cred = resp . data . data [ 0 ] ;
planCache . set ( handleId , cred ) ;
} else {
console . error (
"Failed to load plan with handle" ,
handleId ,
" Got data:" ,
resp . data ,
) ;
}
} catch ( error ) {
console . error (
"Failed to load plan with handle" ,
handleId ,
" Got error:" ,
error ,
) ;
}
}
return cred ;
}
export async function setPlanInCache (
handleId : string ,
planSummary : PlanSummaryRecord ,
) {
planCache . set ( handleId , planSummary ) ;
}
/ * *
*
* @returns { data : Array < OfferSummaryRecord > , hitLimit : boolean true if maximum was hit and there may be more }
* /
export async function getNewOffersToUser (
axios : Axios ,
apiServer : string ,
activeDid : string ,
afterOfferJwtId? : string ,
beforeOfferJwtId? : string ,
) {
let url = ` ${ apiServer } /api/v2/report/offers?recipientDid= ${ activeDid } ` ;
if ( afterOfferJwtId ) {
url += "&afterId=" + afterOfferJwtId ;
}
if ( beforeOfferJwtId ) {
url += "&beforeId=" + beforeOfferJwtId ;
}
const headers = await getHeaders ( activeDid ) ;
const response = await axios . get ( url , { headers } ) ;
return response . data ;
}
/ * *
*
* @returns { data : Array < OfferToPlanSummaryRecord > , hitLimit : boolean true if maximum was hit and there may be more }
* /
export async function getNewOffersToUserProjects (
axios : Axios ,
apiServer : string ,
activeDid : string ,
afterOfferJwtId? : string ,
beforeOfferJwtId? : string ,
) {
let url = ` ${ apiServer } /api/v2/report/offersToPlansOwnedByMe ` ;
if ( afterOfferJwtId ) {
url += "?afterId=" + afterOfferJwtId ;
}
if ( beforeOfferJwtId ) {
url += afterOfferJwtId ? "&" : "?" ;
url += "beforeId=" + beforeOfferJwtId ;
}
const headers = await getHeaders ( activeDid ) ;
const response = await axios . get ( url , { headers } ) ;
return response . data ;
}
/ * *
* Construct GiveAction VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
* /
export function hydrateGive (
vcClaimOrig? : GiveVerifiableCredential ,
fromDid? : string ,
toDid? : string ,
description? : string ,
amount? : number ,
unitCode? : string ,
fulfillsProjectHandleId? : string ,
fulfillsOfferHandleId? : string ,
isTrade : boolean = false ,
imageUrl? : string ,
providerPlanHandleId? : string ,
lastClaimId? : string ,
) : GiveVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim : GiveVerifiableCredential = vcClaimOrig
? R . clone ( vcClaimOrig )
: {
"@context" : SCHEMA_ORG_CONTEXT ,
"@type" : "GiveAction" ,
} ;
if ( lastClaimId ) {
// this is an edit
vcClaim . lastClaimId = lastClaimId ;
delete vcClaim . identifier ;
}
vcClaim . agent = fromDid ? { identifier : fromDid } : undefined ;
vcClaim . recipient = toDid ? { identifier : toDid } : undefined ;
vcClaim . description = description || undefined ;
vcClaim . object =
amount && ! isNaN ( amount )
? { amountOfThisGood : amount , unitCode : unitCode || "HUR" }
: undefined ;
// ensure fulfills is an array
if ( ! Array . isArray ( vcClaim . fulfills ) ) {
vcClaim . fulfills = vcClaim . fulfills ? [ vcClaim . fulfills ] : [ ] ;
}
// ... and replace or add each element, ending with Trade or Donate
// I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action.
vcClaim . fulfills = vcClaim . fulfills . filter (
( elem ) = > elem [ "@type" ] !== "PlanAction" ,
) ;
if ( fulfillsProjectHandleId ) {
vcClaim . fulfills . push ( {
"@type" : "PlanAction" ,
identifier : fulfillsProjectHandleId ,
} ) ;
}
vcClaim . fulfills = vcClaim . fulfills . filter (
( elem ) = > elem [ "@type" ] !== "Offer" ,
) ;
if ( fulfillsOfferHandleId ) {
vcClaim . fulfills . push ( {
"@type" : "Offer" ,
identifier : fulfillsOfferHandleId ,
} ) ;
}
// do Trade/Donate last because current endorser.ch only looks at the first for plans & offers
vcClaim . fulfills = vcClaim . fulfills . filter (
( elem ) = >
elem [ "@type" ] !== "DonateAction" && elem [ "@type" ] !== "TradeAction" ,
) ;
vcClaim . fulfills . push ( { "@type" : isTrade ? "TradeAction" : "DonateAction" } ) ;
vcClaim . image = imageUrl || undefined ;
vcClaim . provider = providerPlanHandleId
? { "@type" : "PlanAction" , identifier : providerPlanHandleId }
: undefined ;
return vcClaim ;
}
/ * *
* For result , see https : //api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param fromDid may be null
* @param toDid
* @param description may be null
* @param amount may be null
* /
export async function createAndSubmitGive (
axios : Axios ,
apiServer : string ,
issuerDid : string ,
fromDid? : string ,
toDid? : string ,
description? : string ,
amount? : number ,
unitCode? : string ,
fulfillsProjectHandleId? : string ,
fulfillsOfferHandleId? : string ,
isTrade : boolean = false ,
imageUrl? : string ,
providerPlanHandleId? : string ,
) : Promise < CreateAndSubmitClaimResult > {
const vcClaim = hydrateGive (
undefined ,
fromDid ,
toDid ,
description ,
amount ,
unitCode ,
fulfillsProjectHandleId ,
fulfillsOfferHandleId ,
isTrade ,
imageUrl ,
providerPlanHandleId ,
undefined ,
) ;
return createAndSubmitClaim (
vcClaim as GenericVerifiableCredential ,
issuerDid ,
apiServer ,
axios ,
) ;
}
/ * *
* For result , see https : //api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param fromDid may be null
* @param toDid may be null if project is provided
* @param description may be null
* @param amount may be null
* /
export async function editAndSubmitGive (
axios : Axios ,
apiServer : string ,
fullClaim : GenericCredWrapper < GiveVerifiableCredential > ,
issuerDid : string ,
fromDid? : string ,
toDid? : string ,
description? : string ,
amount? : number ,
unitCode? : string ,
fulfillsProjectHandleId? : string ,
fulfillsOfferHandleId? : string ,
isTrade : boolean = false ,
imageUrl? : string ,
providerPlanHandleId? : string ,
) : Promise < CreateAndSubmitClaimResult > {
const vcClaim = hydrateGive (
fullClaim . claim ,
fromDid ,
toDid ,
description ,
amount ,
unitCode ,
fulfillsProjectHandleId ,
fulfillsOfferHandleId ,
isTrade ,
imageUrl ,
providerPlanHandleId ,
fullClaim . id ,
) ;
return createAndSubmitClaim (
vcClaim as GenericVerifiableCredential ,
issuerDid ,
apiServer ,
axios ,
) ;
}
/ * *
* Construct Offer VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
* /
export function hydrateOffer (
vcClaimOrig? : OfferVerifiableCredential ,
fromDid? : string ,
toDid? : string ,
itemDescription? : string ,
amount? : number ,
unitCode? : string ,
conditionDescription? : string ,
fulfillsProjectHandleId? : string ,
validThrough? : string ,
lastClaimId? : string ,
) : OfferVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim : OfferVerifiableCredential = vcClaimOrig
? R . clone ( vcClaimOrig )
: {
"@context" : SCHEMA_ORG_CONTEXT ,
"@type" : "Offer" ,
} ;
if ( lastClaimId ) {
// this is an edit
vcClaim . lastClaimId = lastClaimId ;
delete vcClaim . identifier ;
}
vcClaim . offeredBy = fromDid ? { identifier : fromDid } : undefined ;
vcClaim . recipient = toDid ? { identifier : toDid } : undefined ;
vcClaim . description = conditionDescription || undefined ;
vcClaim . includesObject =
amount && ! isNaN ( amount )
? { amountOfThisGood : amount , unitCode : unitCode || "HUR" }
: undefined ;
if ( itemDescription || fulfillsProjectHandleId ) {
vcClaim . itemOffered = vcClaim . itemOffered || { } ;
vcClaim . itemOffered . description = itemDescription || undefined ;
if ( fulfillsProjectHandleId ) {
vcClaim . itemOffered . isPartOf = {
"@type" : "PlanAction" ,
identifier : fulfillsProjectHandleId ,
} ;
}
}
vcClaim . validThrough = validThrough || undefined ;
return vcClaim ;
}
/ * *
* For result , see https : //api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param identity
* @param description may be null
* @param amount may be null
* @param validThrough 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 ,
issuerDid : string ,
itemDescription : string ,
amount? : number ,
unitCode? : string ,
conditionDescription? : string ,
validThrough? : string ,
recipientDid? : string ,
fulfillsProjectHandleId? : string ,
) : Promise < CreateAndSubmitClaimResult > {
const vcClaim = hydrateOffer (
undefined ,
issuerDid ,
recipientDid ,
itemDescription ,
amount ,
unitCode ,
conditionDescription ,
fulfillsProjectHandleId ,
validThrough ,
undefined ,
) ;
return createAndSubmitClaim (
vcClaim as OfferVerifiableCredential ,
issuerDid ,
apiServer ,
axios ,
) ;
}
export async function editAndSubmitOffer (
axios : Axios ,
apiServer : string ,
fullClaim : GenericCredWrapper < OfferVerifiableCredential > ,
issuerDid : string ,
itemDescription : string ,
amount? : number ,
unitCode? : string ,
conditionDescription? : string ,
validThrough? : string ,
recipientDid? : string ,
fulfillsProjectHandleId? : string ,
) : Promise < CreateAndSubmitClaimResult > {
const vcClaim = hydrateOffer (
fullClaim . claim ,
issuerDid ,
recipientDid ,
itemDescription ,
amount ,
unitCode ,
conditionDescription ,
fulfillsProjectHandleId ,
validThrough ,
fullClaim . id ,
) ;
return createAndSubmitClaim (
vcClaim as OfferVerifiableCredential ,
issuerDid ,
apiServer ,
axios ,
) ;
}
// similar logic is found in endorser-mobile
export const createAndSubmitConfirmation = async (
issuerDid : string ,
claim : GenericVerifiableCredential ,
lastClaimId : string , // used to set the lastClaimId
handleId : string | undefined ,
apiServer : string ,
axios : Axios ,
) = > {
const goodClaim = removeSchemaContext (
removeVisibleToDids (
addLastClaimOrHandleAsIdIfMissing ( claim , lastClaimId , handleId ) ,
) ,
) ;
const confirmationClaim : GenericVerifiableCredential = {
"@context" : SCHEMA_ORG_CONTEXT ,
"@type" : "AgreeAction" ,
object : goodClaim ,
} ;
return createAndSubmitClaim ( confirmationClaim , issuerDid , apiServer , axios ) ;
} ;
export async function createAndSubmitClaim (
vcClaim : GenericVerifiableCredential ,
issuerDid : string ,
apiServer : string ,
axios : Axios ,
) : Promise < CreateAndSubmitClaimResult > {
try {
const vcPayload = {
vc : {
"@context" : [ "https://www.w3.org/2018/credentials/v1" ] ,
type : [ "VerifiableCredential" ] ,
credentialSubject : vcClaim ,
} ,
} ;
const vcJwt : string = await createEndorserJwtForDid ( issuerDid , vcPayload ) ;
// Make the xhr request payload
const payload = JSON . stringify ( { jwtEncoded : vcJwt } ) ;
const url = ` ${ apiServer } /api/v2/claim ` ;
const response = await axios . post ( url , payload , {
headers : {
"Content-Type" : "application/json" ,
} ,
} ) ;
return { type : "success" , response } ;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch ( error : any ) {
console . error ( "Error submitting claim:" , error ) ;
const errorMessage : string =
error . response ? . data ? . error ? . message ||
error . message ||
"Got some error submitting the claim. Check your permissions, network, and error logs." ;
return {
type : "error" ,
error : {
error : errorMessage ,
} ,
} ;
}
}
export async function generateEndorserJwtUrlForAccount (
account : Account ,
isRegistered? : boolean ,
name? : string ,
profileImageUrl? : string ,
// note that including the next key pushes QR codes to the next resolution smaller
includeNextKeyIfDerived? : boolean ,
) {
const publicKeyHex = account . publicKeyHex ;
const publicEncKey = Buffer . from ( publicKeyHex , "hex" ) . toString ( "base64" ) ;
const contactInfo = {
iat : Date.now ( ) ,
iss : account.did ,
own : {
did : account.did ,
name : name ? ? "" ,
publicEncKey ,
registered : ! ! isRegistered ,
} as UserInfo ,
} ;
if ( profileImageUrl ) {
contactInfo . own . profileImageUrl = profileImageUrl ;
}
// Add the next key -- not recommended for the QR code for such a high resolution
if ( includeNextKeyIfDerived && account ? . mnemonic && account ? . derivationPath ) {
const newDerivPath = nextDerivationPath ( account . derivationPath as string ) ;
const nextPublicHex = deriveAddress (
account . mnemonic as string ,
newDerivPath ,
) [ 2 ] ;
const nextPublicEncKey = Buffer . from ( nextPublicHex , "hex" ) ;
const nextPublicEncKeyHash = sha256 ( nextPublicEncKey ) ;
const nextPublicEncKeyHashBase64 =
Buffer . from ( nextPublicEncKeyHash ) . toString ( "base64" ) ;
contactInfo . own . nextPublicEncKeyHash = nextPublicEncKeyHashBase64 ;
}
const vcJwt = await createEndorserJwtForDid ( account . did , contactInfo ) ;
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI ;
return viewPrefix + vcJwt ;
}
export async function createEndorserJwtForDid (
issuerDid : string ,
payload : object ,
expiresIn? : number ,
) {
const account = await retrieveFullyDecryptedAccount ( issuerDid ) ;
return createEndorserJwtForKey ( account as KeyMeta , payload , expiresIn ) ;
}
/ * *
* An AcceptAction is when someone accepts some contract or pledge .
*
* @param claim has properties '@context' & '@type'
* @return true if the claim is a schema . org AcceptAction
* /
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isAccept = ( claim : Record < string , any > ) = > {
return (
claim &&
claim [ "@context" ] === SCHEMA_ORG_CONTEXT &&
claim [ "@type" ] === "AcceptAction"
) ;
} ;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isOffer = ( claim : Record < string , any > ) = > {
return (
claim &&
claim [ "@context" ] === SCHEMA_ORG_CONTEXT &&
claim [ "@type" ] === "Offer"
) ;
} ;
export function currencyShortWordForCode ( unitCode : string , single : boolean ) {
return unitCode === "HUR" ? ( single ? "hour" : "hours" ) : unitCode ;
}
export function displayAmount ( code : string , amt : number ) {
return "" + amt + " " + currencyShortWordForCode ( code , amt === 1 ) ;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
export const capitalizeAndInsertSpacesBeforeCaps = ( text : string ) = > {
return ! text
? ""
: text [ 0 ] . toUpperCase ( ) + text . substr ( 1 ) . replace ( /([A-Z])/g , " $1" ) ;
} ;
/ * *
return readable summary of claim , or something generic
similar code is also contained in endorser - mobile
* * /
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const claimSummary = (
claim : GenericCredWrapper < GenericVerifiableCredential > ,
) = > {
if ( ! claim ) {
// to differentiate from "something" above
return "something" ;
}
let specificClaim :
| GenericVerifiableCredential
| GenericCredWrapper < GenericVerifiableCredential > = claim ;
if ( claim . claim ) {
// probably a Verified Credential
// eslint-disable-next-line @typescript-eslint/no-explicit-any
specificClaim = claim . claim ;
}
if ( Array . isArray ( specificClaim ) ) {
if ( specificClaim . length === 1 ) {
specificClaim = specificClaim [ 0 ] ;
} else {
return "multiple claims" ;
}
}
const type = specificClaim [ "@type" ] ;
if ( ! type ) {
return "a claim" ;
} else {
let typeExpl = capitalizeAndInsertSpacesBeforeCaps ( type ) ;
if ( typeExpl === "Person" ) {
typeExpl += " claim" ;
}
return "a " + typeExpl ;
}
} ;
/ * *
return readable description of claim if possible , as a past - tense action
identifiers is a list of objects with a 'did' field , each representing the user
contacts is a list of objects with a 'did' field for others and a 'name' field for their name
similar code is also contained in endorser - mobile
* * /
export const claimSpecialDescription = (
record : GenericCredWrapper < GenericVerifiableCredential > ,
activeDid : string ,
identifiers : Array < string > ,
contacts : Array < Contact > ,
) = > {
let claim = record . claim ;
if ( claim . claim ) {
// it's probably a Verified Credential
claim = claim . claim ;
}
const issuer = didInfo ( record . issuer , activeDid , identifiers , contacts ) ;
const type = claim [ "@type" ] || "UnknownType" ;
if ( type === "AgreeAction" ) {
return issuer + " agreed with " + claimSummary ( claim . object ) ;
} else if ( isAccept ( claim ) ) {
return issuer + " accepted " + claimSummary ( claim . object ) ;
} else if ( type === "GiveAction" ) {
// agent.did is for legacy data, before March 2023
const giver = claim . agent ? . identifier || claim . agent ? . did ;
const giverInfo = didInfo ( giver , activeDid , identifiers , contacts ) ;
let gaveAmount = claim . object ? . amountOfThisGood
? displayAmount ( claim . object . unitCode , claim . object . amountOfThisGood )
: "" ;
if ( claim . description ) {
if ( gaveAmount ) {
gaveAmount = gaveAmount + ", and also: " ;
}
gaveAmount = gaveAmount + claim . description ;
}
if ( ! gaveAmount ) {
gaveAmount = "something not described" ;
}
// recipient.did is for legacy data, before March 2023
const gaveRecipientId = claim . recipient ? . identifier || claim . recipient ? . did ;
const gaveRecipientInfo = gaveRecipientId
? " to " + didInfo ( gaveRecipientId , activeDid , identifiers , contacts )
: "" ;
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount ;
} else if ( type === "JoinAction" ) {
// agent.did is for legacy data, before March 2023
const agent = claim . agent ? . identifier || claim . agent ? . did ;
const contactInfo = didInfo ( agent , activeDid , identifiers , contacts ) ;
let eventOrganizer =
claim . event && claim . event . organizer && claim . event . organizer . name ;
eventOrganizer = eventOrganizer || "" ;
let eventName = claim . event && claim . event . name ;
eventName = eventName ? " " + eventName : "" ;
let fullEvent = eventOrganizer + eventName ;
fullEvent = fullEvent ? " attended the " + fullEvent : "" ;
let eventDate = claim . event && claim . event . startTime ;
eventDate = eventDate ? " at " + eventDate : "" ;
return contactInfo + fullEvent + eventDate ;
} else if ( isOffer ( claim ) ) {
const offerer = claim . offeredBy ? . identifier ;
const contactInfo = didInfo ( offerer , activeDid , identifiers , contacts ) ;
let offering = "" ;
if ( claim . includesObject ) {
offering +=
" " +
displayAmount (
claim . includesObject . unitCode ,
claim . includesObject . amountOfThisGood ,
) ;
}
if ( claim . itemOffered ? . description ) {
offering += ", saying: " + claim . itemOffered ? . description ;
}
// recipient.did is for legacy data, before March 2023
const offerRecipientId =
claim . recipient ? . identifier || claim . recipient ? . did ;
const offerRecipientInfo = offerRecipientId
? " to " + didInfo ( offerRecipientId , activeDid , identifiers , contacts )
: "" ;
return contactInfo + " offered" + offering + offerRecipientInfo ;
} else if ( type === "PlanAction" ) {
const claimer = claim . agent ? . identifier || record . issuer ;
const claimerInfo = didInfo ( claimer , activeDid , identifiers , contacts ) ;
return claimerInfo + " announced a project: " + claim . name ;
} else if ( type === "Tenure" ) {
// party.did is for legacy data, before March 2023
const claimer = claim . party ? . identifier || claim . party ? . did ;
const contactInfo = didInfo ( claimer , activeDid , identifiers , contacts ) ;
const polygon = claim . spatialUnit ? . geo ? . polygon || "" ;
return (
contactInfo +
" possesses [" +
polygon . substring ( 0 , polygon . indexOf ( " " ) ) +
"...]"
) ;
} else {
return (
issuer +
" declared " +
claimSummary ( claim as GenericCredWrapper < GenericVerifiableCredential > )
) ;
}
} ;
export const BVC_MEETUPS_PROJECT_CLAIM_ID =
import . meta . env . VITE_BVC_MEETUPS_PROJECT_CLAIM_ID ||
"https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H" ; // production value, which seems like the safest value if forgotten
export const bvcMeetingJoinClaim = ( did : string , startTime : string ) = > {
return {
"@context" : SCHEMA_ORG_CONTEXT ,
"@type" : "JoinAction" ,
agent : {
identifier : did ,
} ,
event : {
organizer : {
name : "Bountiful Voluntaryist Community" ,
} ,
name : "Saturday Morning Meeting" ,
startTime : startTime ,
} ,
} ;
} ;
export async function createEndorserJwtVcFromClaim (
issuerDid : string ,
claim : object ,
) {
// Make a payload for the claim
const vcPayload = {
vc : {
"@context" : [ "https://www.w3.org/2018/credentials/v1" ] ,
type : [ "VerifiableCredential" ] ,
credentialSubject : claim ,
} ,
} ;
return createEndorserJwtForDid ( issuerDid , vcPayload ) ;
}
export async function createInviteJwt (
activeDid : string ,
contact? : Contact ,
inviteId? : string ,
expiresIn? : number ,
) : Promise < string > {
const vcClaim : RegisterVerifiableCredential = {
"@context" : SCHEMA_ORG_CONTEXT ,
"@type" : "RegisterAction" ,
agent : { identifier : activeDid } ,
object : SERVICE_ID ,
} ;
if ( contact ) {
vcClaim . participant = { identifier : contact.did } ;
}
if ( inviteId ) {
vcClaim . identifier = inviteId ;
}
// Make a payload for the claim
const vcPayload = {
vc : {
"@context" : [ "https://www.w3.org/2018/credentials/v1" ] ,
type : [ "VerifiableCredential" ] ,
credentialSubject : vcClaim ,
} ,
} ;
// Create a signature using private key of identity
const vcJwt = await createEndorserJwtForDid ( activeDid , vcPayload , expiresIn ) ;
return vcJwt ;
}
export async function register (
activeDid : string ,
apiServer : string ,
axios : Axios ,
contact : Contact ,
) : Promise < { success? : boolean ; error? : string } > {
const vcJwt = await createInviteJwt ( activeDid , contact ) ;
const url = apiServer + "/api/v2/claim" ;
const resp = await axios . post ( url , { jwtEncoded : vcJwt } ) ;
if ( resp . data ? . success ? . handleId ) {
return { success : true } ;
} else if ( resp . data ? . success ? . embeddedRecordError ) {
let message =
"There was some problem with the registration and so it may not be complete." ;
if ( typeof resp . data . success . embeddedRecordError == "string" ) {
message += " " + resp . data . success . embeddedRecordError ;
}
return { error : message } ;
} else {
console . error ( resp ) ;
return { error : "Got a server error when registering." } ;
}
}
export async function setVisibilityUtil (
activeDid : string ,
apiServer : string ,
axios : Axios ,
db : NonsensitiveDexie ,
contact : Contact ,
visibility : boolean ,
) {
if ( ! activeDid ) {
return { error : "Cannot set visibility without an identifier." } ;
}
const url =
apiServer + "/api/report/" + ( visibility ? "canSeeMe" : "cannotSeeMe" ) ;
const headers = await getHeaders ( activeDid ) ;
const payload = JSON . stringify ( { did : contact.did } ) ;
try {
const resp = await axios . post ( url , payload , { headers } ) ;
if ( resp . status === 200 ) {
const success = resp . data . success ;
if ( success ) {
db . contacts . update ( contact . did , { seesMe : visibility } ) ;
}
return { success } ;
} else {
console . error (
"Got some bad server response when setting visibility: " ,
resp . status ,
resp ,
) ;
const message =
resp . data . error ? . message || "Got some error setting visibility." ;
return { error : message } ;
}
} catch ( err ) {
console . error ( "Got some error when setting visibility:" , err ) ;
return { error : "Check connectivity and try again." } ;
}
}
/ * *
* Fetches rate limits from the Endorser server .
*
* @param apiServer endorser server URL string
* @param axios Axios instance
* @param { string } issuerDid - The DID for which to check rate limits .
* @returns { Promise < AxiosResponse > } The Axios response object .
* /
export async function fetchEndorserRateLimits (
apiServer : string ,
axios : Axios ,
issuerDid : string ,
) {
const url = ` ${ apiServer } /api/report/rateLimits ` ;
const headers = await getHeaders ( issuerDid ) ;
return await axios . get ( url , { headers } as AxiosRequestConfig ) ;
}
/ * *
* Fetches rate limits from the image server .
*
* @param apiServer image server URL string
* @param axios Axios instance
* @param { string } issuerDid - The DID for which to check rate limits .
* @returns { Promise < AxiosResponse > } The Axios response object .
* /
export async function fetchImageRateLimits ( axios : Axios , issuerDid : string ) {
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits" ;
const headers = await getHeaders ( issuerDid ) ;
return await axios . get ( url , { headers } as AxiosRequestConfig ) ;
}