@attestprotocol/stellar-sdk
Version:
Stellar implementation of the Attest Protocol SDK
610 lines (539 loc) • 18.1 kB
text/typescript
/**
* Stellar Schema Encoder - Standardized schema definition and data encoding for Stellar attestations
*
* Inspired by EAS (Ethereum Attestation Service) but adapted for Stellar/Soroban contracts.
* Provides type-safe schema definitions and encoding/decoding utilities.
*/
import { Address, xdr } from '@stellar/stellar-sdk'
/**
* Supported Stellar attestation data types
*/
export enum StellarDataType {
STRING = 'string',
BOOL = 'bool',
U32 = 'u32',
U64 = 'u64',
I32 = 'i32',
I64 = 'i64',
I128 = 'i128',
ADDRESS = 'address',
BYTES = 'bytes',
SYMBOL = 'symbol',
ARRAY = 'array',
OPTION = 'option',
MAP = 'map',
TIMESTAMP = 'timestamp',
AMOUNT = 'amount'
}
/**
* Schema field definition
*/
export interface SchemaField {
name: string
type: StellarDataType | string
optional?: boolean
description?: string
validation?: {
min?: number
max?: number
pattern?: string
enum?: string[]
}
}
/**
* Complete schema definition with metadata
*/
export interface StellarSchemaDefinition {
name: string
version: string
description: string
fields: SchemaField[]
metadata?: {
category?: string
tags?: string[]
authority?: string
revocable?: boolean
expirable?: boolean
}
}
/**
* Encoded attestation data ready for contract submission
*/
export interface EncodedAttestationData {
schemaHash: string
encodedData: string
decodedData: Record<string, any>
schema: StellarSchemaDefinition
}
/**
* Schema validation error
*/
export class SchemaValidationError extends Error {
constructor(message: string, public field?: string) {
super(message)
this.name = 'SchemaValidationError'
}
}
/**
* Stellar Schema Encoder - Provides standardized schema definition and data encoding
*/
export class StellarSchemaEncoder {
private schema: StellarSchemaDefinition
constructor(schema: StellarSchemaDefinition) {
this.validateSchema(schema)
this.schema = schema
}
/**
* Get the schema definition
*/
getSchema(): StellarSchemaDefinition {
return { ...this.schema }
}
/**
* Generate a unique hash for this schema
*/
getSchemaHash(): string {
const schemaString = JSON.stringify({
name: this.schema.name,
version: this.schema.version,
fields: this.schema.fields.map(f => ({ name: f.name, type: f.type, optional: f.optional }))
})
const encoder = new TextEncoder()
const data = encoder.encode(schemaString)
return Array.from(new Uint8Array(data.slice(0, 32)))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Encode attestation data according to the schema
*/
async encodeData(data: Record<string, any>): Promise<EncodedAttestationData> {
this.validateData(data)
const encodedData = JSON.stringify(this.processDataForEncoding(data))
const schemaHash = this.getSchemaHash()
return {
schemaHash,
encodedData,
decodedData: { ...data },
schema: this.getSchema()
}
}
/**
* Decode attestation data from encoded format
*/
decodeData(encodedData: string): Record<string, any> {
try {
const parsed = JSON.parse(encodedData)
return this.processDataForDecoding(parsed)
} catch (error) {
throw new SchemaValidationError(`Failed to decode data: ${error}`)
}
}
/**
* Validate data against the schema
*/
validateData(data: Record<string, any>): void {
// Check required fields
for (const field of this.schema.fields) {
if (!field.optional && !(field.name in data)) {
throw new SchemaValidationError(`Required field '${field.name}' is missing`, field.name)
}
}
// Validate each field
for (const [key, value] of Object.entries(data)) {
const field = this.schema.fields.find(f => f.name === key)
if (!field) {
throw new SchemaValidationError(`Unknown field '${key}'`, key)
}
this.validateFieldValue(field, value)
}
}
/**
* Generate default values for a schema
*/
generateDefaults(): Record<string, any> {
const defaults: Record<string, any> = {}
for (const field of this.schema.fields) {
if (field.optional) continue
defaults[field.name] = this.getDefaultValue(field.type)
}
return defaults
}
/**
* Convert schema to JSON Schema format for external compatibility
*/
toJSONSchema(): object {
const properties: Record<string, any> = {}
const required: string[] = []
for (const field of this.schema.fields) {
properties[field.name] = {
type: this.stellarTypeToJSONSchemaType(field.type),
description: field.description
}
if (field.validation) {
Object.assign(properties[field.name], field.validation)
}
if (!field.optional) {
required.push(field.name)
}
}
return {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
title: this.schema.name,
description: this.schema.description,
version: this.schema.version,
properties,
required,
additionalProperties: false
}
}
/**
* Create a schema encoder from JSON Schema
*/
static fromJSONSchema(jsonSchema: any): StellarSchemaEncoder {
const fields: SchemaField[] = []
for (const [name, prop] of Object.entries(jsonSchema.properties || {})) {
const property = prop as any
fields.push({
name,
type: StellarSchemaEncoder.jsonSchemaTypeToStellarType(property.type),
optional: !jsonSchema.required?.includes(name),
description: property.description,
validation: {
min: property.minimum,
max: property.maximum,
pattern: property.pattern,
enum: property.enum
}
})
}
const schema: StellarSchemaDefinition = {
name: jsonSchema.title || 'Untitled Schema',
version: jsonSchema.version || '1.0.0',
description: jsonSchema.description || '',
fields
}
return new StellarSchemaEncoder(schema)
}
/**
* Validate schema definition
*/
private validateSchema(schema: StellarSchemaDefinition): void {
if (!schema.name || typeof schema.name !== 'string') {
throw new SchemaValidationError('Schema must have a valid name')
}
if (!schema.version || typeof schema.version !== 'string') {
throw new SchemaValidationError('Schema must have a valid version')
}
if (!schema.fields || !Array.isArray(schema.fields) || schema.fields.length === 0) {
throw new SchemaValidationError('Schema must have at least one field')
}
// Validate each field
const fieldNames = new Set<string>()
for (const field of schema.fields) {
if (!field.name || typeof field.name !== 'string') {
throw new SchemaValidationError('Each field must have a valid name')
}
if (fieldNames.has(field.name)) {
throw new SchemaValidationError(`Duplicate field name: ${field.name}`)
}
fieldNames.add(field.name)
if (!this.isValidStellarType(field.type)) {
throw new SchemaValidationError(`Invalid type '${field.type}' for field '${field.name}'`)
}
}
}
/**
* Validate individual field value
*/
private validateFieldValue(field: SchemaField, value: any): void {
if (value === null || value === undefined) {
if (!field.optional) {
throw new SchemaValidationError(`Field '${field.name}' cannot be null`, field.name)
}
return
}
// Type-specific validation
switch (field.type) {
case StellarDataType.STRING:
case StellarDataType.SYMBOL:
if (typeof value !== 'string') {
throw new SchemaValidationError(`Field '${field.name}' must be a string`, field.name)
}
break
case StellarDataType.BOOL:
if (typeof value !== 'boolean') {
throw new SchemaValidationError(`Field '${field.name}' must be a boolean`, field.name)
}
break
case StellarDataType.U32:
case StellarDataType.U64:
case StellarDataType.I32:
case StellarDataType.I64:
case StellarDataType.I128:
case StellarDataType.AMOUNT:
if (typeof value !== 'number' && typeof value !== 'bigint') {
throw new SchemaValidationError(`Field '${field.name}' must be a number`, field.name)
}
break
case StellarDataType.ADDRESS:
if (typeof value !== 'string' || !this.isValidStellarAddress(value)) {
throw new SchemaValidationError(`Field '${field.name}' must be a valid Stellar address`, field.name)
}
break
case StellarDataType.TIMESTAMP:
if (typeof value !== 'number' && typeof value !== 'string') {
throw new SchemaValidationError(`Field '${field.name}' must be a timestamp`, field.name)
}
break
}
// Validation rules
if (field.validation) {
if (field.validation.enum && !field.validation.enum.includes(value)) {
throw new SchemaValidationError(
`Field '${field.name}' must be one of: ${field.validation.enum.join(', ')}`,
field.name
)
}
if (typeof value === 'string' && field.validation.pattern) {
if (!new RegExp(field.validation.pattern).test(value)) {
throw new SchemaValidationError(`Field '${field.name}' does not match pattern`, field.name)
}
}
if (typeof value === 'number') {
if (field.validation.min !== undefined && value < field.validation.min) {
throw new SchemaValidationError(`Field '${field.name}' is below minimum value`, field.name)
}
if (field.validation.max !== undefined && value > field.validation.max) {
throw new SchemaValidationError(`Field '${field.name}' exceeds maximum value`, field.name)
}
}
}
}
/**
* Process data for encoding (type conversions, etc.)
*/
private processDataForEncoding(data: Record<string, any>): Record<string, any> {
const processed: Record<string, any> = {}
for (const [key, value] of Object.entries(data)) {
const field = this.schema.fields.find(f => f.name === key)
if (!field) continue
switch (field.type) {
case StellarDataType.ADDRESS:
// Ensure address is in proper format
processed[key] = typeof value === 'string' ? value : value.toString()
break
case StellarDataType.TIMESTAMP:
// Convert to Unix timestamp
processed[key] = typeof value === 'string' ? new Date(value).getTime() : value
break
case StellarDataType.I128:
case StellarDataType.AMOUNT:
// Handle big numbers
processed[key] = typeof value === 'bigint' ? value.toString() : value
break
default:
processed[key] = value
}
}
return processed
}
/**
* Process data for decoding (reverse of encoding)
*/
private processDataForDecoding(data: Record<string, any>): Record<string, any> {
const processed: Record<string, any> = {}
for (const [key, value] of Object.entries(data)) {
const field = this.schema.fields.find(f => f.name === key)
if (!field) {
processed[key] = value
continue
}
switch (field.type) {
case StellarDataType.I128:
case StellarDataType.AMOUNT:
// Convert back to BigInt if it was a string
processed[key] = typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : value
break
default:
processed[key] = value
}
}
return processed
}
/**
* Get default value for a type
*/
private getDefaultValue(type: string): any {
switch (type) {
case StellarDataType.STRING:
case StellarDataType.SYMBOL:
return ''
case StellarDataType.BOOL:
return false
case StellarDataType.U32:
case StellarDataType.U64:
case StellarDataType.I32:
case StellarDataType.I64:
case StellarDataType.AMOUNT:
return 0
case StellarDataType.I128:
return BigInt(0)
case StellarDataType.ADDRESS:
return 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'
case StellarDataType.TIMESTAMP:
return Date.now()
case StellarDataType.BYTES:
return new Uint8Array(0)
case StellarDataType.ARRAY:
return []
case StellarDataType.MAP:
return {}
default:
return null
}
}
/**
* Check if a type is valid Stellar type
*/
private isValidStellarType(type: string): boolean {
return Object.values(StellarDataType).includes(type as StellarDataType) ||
type.startsWith('array<') ||
type.startsWith('option<') ||
type.startsWith('map<')
}
/**
* Convert Stellar type to JSON Schema type
*/
private stellarTypeToJSONSchemaType(stellarType: string): string {
switch (stellarType) {
case StellarDataType.STRING:
case StellarDataType.SYMBOL:
case StellarDataType.ADDRESS:
return 'string'
case StellarDataType.BOOL:
return 'boolean'
case StellarDataType.U32:
case StellarDataType.U64:
case StellarDataType.I32:
case StellarDataType.I64:
case StellarDataType.I128:
case StellarDataType.AMOUNT:
case StellarDataType.TIMESTAMP:
return 'number'
case StellarDataType.ARRAY:
return 'array'
case StellarDataType.MAP:
return 'object'
default:
return 'string'
}
}
/**
* Convert JSON Schema type to Stellar type
*/
private static jsonSchemaTypeToStellarType(jsonType: string): string {
switch (jsonType) {
case 'string':
return StellarDataType.STRING
case 'boolean':
return StellarDataType.BOOL
case 'number':
case 'integer':
return StellarDataType.I64
case 'array':
return StellarDataType.ARRAY
case 'object':
return StellarDataType.MAP
default:
return StellarDataType.STRING
}
}
/**
* Validate Stellar address format
*/
private isValidStellarAddress(address: string): boolean {
try {
Address.fromString(address)
return true
} catch {
return false
}
}
}
/**
* Pre-defined schema encoders for common use cases
*/
export class StellarSchemaRegistry {
private static schemas = new Map<string, StellarSchemaEncoder>()
/**
* Register a schema encoder
*/
static register(name: string, encoder: StellarSchemaEncoder): void {
this.schemas.set(name, encoder)
}
/**
* Get a registered schema encoder
*/
static get(name: string): StellarSchemaEncoder | undefined {
return this.schemas.get(name)
}
/**
* List all registered schema names
*/
static list(): string[] {
return Array.from(this.schemas.keys())
}
/**
* Initialize with common schemas
*/
static initializeDefaults(): void {
// Identity verification schema
this.register('identity-verification', new StellarSchemaEncoder({
name: 'Identity Verification',
version: '1.0.0',
description: 'Standard identity verification attestation',
fields: [
{ name: 'fullName', type: StellarDataType.STRING, description: 'Legal full name' },
{ name: 'dateOfBirth', type: StellarDataType.TIMESTAMP, description: 'Date of birth' },
{ name: 'nationality', type: StellarDataType.STRING, description: 'Nationality' },
{ name: 'documentType', type: StellarDataType.STRING, validation: { enum: ['passport', 'drivers_license', 'national_id'] } },
{ name: 'verificationLevel', type: StellarDataType.STRING, validation: { enum: ['basic', 'enhanced', 'premium'] } },
{ name: 'verifiedBy', type: StellarDataType.ADDRESS, description: 'Verifying authority address' }
]
}))
// Academic credential schema
this.register('academic-credential', new StellarSchemaEncoder({
name: 'Academic Credential',
version: '1.0.0',
description: 'University degree or academic achievement',
fields: [
{ name: 'studentName', type: StellarDataType.STRING, description: 'Name of the student' },
{ name: 'institution', type: StellarDataType.STRING, description: 'Educational institution' },
{ name: 'degree', type: StellarDataType.STRING, description: 'Type of degree' },
{ name: 'fieldOfStudy', type: StellarDataType.STRING, description: 'Major or field' },
{ name: 'graduationDate', type: StellarDataType.TIMESTAMP, description: 'Graduation date' },
{ name: 'gpa', type: StellarDataType.U32, optional: true, validation: { min: 0, max: 400 } }, // GPA * 100
{ name: 'honors', type: StellarDataType.STRING, optional: true, validation: { enum: ['summa_cum_laude', 'magna_cum_laude', 'cum_laude', 'none'] } }
]
}))
// Professional certification schema
this.register('professional-certification', new StellarSchemaEncoder({
name: 'Professional Certification',
version: '1.0.0',
description: 'Professional certification or license',
fields: [
{ name: 'holderName', type: StellarDataType.STRING, description: 'Certification holder name' },
{ name: 'certificationName', type: StellarDataType.STRING, description: 'Name of certification' },
{ name: 'issuingOrganization', type: StellarDataType.STRING, description: 'Issuing organization' },
{ name: 'certificationNumber', type: StellarDataType.STRING, description: 'Certification number' },
{ name: 'issueDate', type: StellarDataType.TIMESTAMP, description: 'Issue date' },
{ name: 'expirationDate', type: StellarDataType.TIMESTAMP, optional: true, description: 'Expiration date' },
{ name: 'level', type: StellarDataType.STRING, validation: { enum: ['entry', 'associate', 'professional', 'expert', 'master'] } }
]
}))
}
}
// Initialize default schemas
StellarSchemaRegistry.initializeDefaults()