@byzantine/vault-sdk
Version:
Byzantine Vault SDK for creating and managing vaults on Ethereum for restaking strategies
335 lines (334 loc) • 14.2 kB
JavaScript
;
// @ts-check
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertURItoMetadata = exports.convertMetadataToURI = exports.METADATA_VALIDATION = void 0;
exports.getVaultMetadata = getVaultMetadata;
exports.setMetadata = setMetadata;
const utils_1 = require("../../utils");
// Validation patterns for metadata and URIs
exports.METADATA_VALIDATION = {
// Regular expression to validate URLs
URL_PATTERN: /^(https?):\/\/[^\s/$.?#].[^\s]*$/,
// Regular expression to validate IPFS URIs
IPFS_URI_PATTERN: /^ipfs:\/\/[a-zA-Z0-9]{46}$/,
// Regular expression to validate base64 data URIs
DATA_URI_PATTERN: /^data:application\/json;base64,[a-zA-Z0-9+/=]+$/,
// Supported image extensions
IMAGE_EXTENSIONS: [".png", ".jpg", ".jpeg"],
// Maximum metadata size in bytes (1MB)
MAX_METADATA_SIZE: 1048576,
// Maximum length for text fields
MAX_NAME_LENGTH: 100,
// Maximum length for text fields
MAX_DESCRIPTION_LENGTH: 5000,
// Regular expression for Ethereum addresses
ETH_ADDRESS_PATTERN: /^0x[0-9a-fA-F]{40}$/,
// Social media URL validation patterns
SOCIAL_TWITTER_PATTERN: /^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com\/|x\.com\/)([a-zA-Z0-9_]{1,15})(?:\/)?$/,
SOCIAL_DISCORD_PATTERN: /^(?:https?:\/\/)?(?:www\.)?(?:discord\.gg\/|discord\.com\/invite\/)([a-zA-Z0-9-]+)(?:\/)?$/,
SOCIAL_TELEGRAM_PATTERN: /^(?:https?:\/\/)?(?:www\.)?(?:t\.me\/|telegram\.me\/)([a-zA-Z0-9_]{5,32})(?:\/)?$/,
SOCIAL_GITHUB_PATTERN: /^(?:https?:\/\/)?(?:www\.)?github\.com\/([a-zA-Z0-9-]+)(?:\/)?$/,
};
/**
* Get the metadata URI of the vault
* @param vaultContract - The vault contract instance
* @returns The metadata URI string
*/
async function getVaultMetadata(vaultContract) {
// The contract may not have a direct method to get the metadata URI
// We need to check the specific implementation
if (typeof vaultContract.metadataURI === "function") {
const metadataURI = await (0, utils_1.callContractMethod)(vaultContract, "metadataURI");
return await (0, utils_1.callContractMethod)(vaultContract, "metadata", metadataURI);
}
else {
throw Error("This vault does not support direct metadataURI access");
}
}
/**
* Validates metadata object structure and content
* @param metadata - The metadata object to validate
* @throws Error if validation fails
*/
const validateMetadata = (metadata) => {
// Check required fields
if (!metadata.name) {
throw Error("Metadata must include a 'name' field");
}
if (!metadata.description) {
throw Error("Metadata must include a 'description' field");
}
// Validate field lengths
if (metadata.name.length > exports.METADATA_VALIDATION.MAX_NAME_LENGTH) {
throw Error(`Name exceeds maximum length of ${exports.METADATA_VALIDATION.MAX_NAME_LENGTH} characters`);
}
if (metadata.description.length > exports.METADATA_VALIDATION.MAX_DESCRIPTION_LENGTH) {
throw Error(`Description exceeds maximum length of ${exports.METADATA_VALIDATION.MAX_DESCRIPTION_LENGTH} characters`);
}
// Validate image URL if present
if (metadata.image_url) {
const isValidUrl = exports.METADATA_VALIDATION.URL_PATTERN.test(metadata.image_url) ||
metadata.image_url.startsWith("ipfs://") ||
metadata.image_url.startsWith("data:image/") ||
/^https?:\/\/raw\.githubusercontent\.com\//.test(metadata.image_url);
if (!isValidUrl) {
throw Error("Image URL must be a valid HTTP/HTTPS URL, IPFS URI, data URI, or a raw.githubusercontent.com URL");
}
// If it's a regular URL, validate the file extension
if (exports.METADATA_VALIDATION.URL_PATTERN.test(metadata.image_url) ||
/^https?:\/\/raw\.githubusercontent\.com\//.test(metadata.image_url)) {
const hasValidExtension = exports.METADATA_VALIDATION.IMAGE_EXTENSIONS.some((ext) => metadata.image_url.toLowerCase().endsWith(ext));
if (!hasValidExtension) {
throw Error(`Image URL must end with one of the following extensions: ${exports.METADATA_VALIDATION.IMAGE_EXTENSIONS.join(", ")}`);
}
}
}
// Validate social media URLs if present
if (metadata.social_twitter &&
!exports.METADATA_VALIDATION.SOCIAL_TWITTER_PATTERN.test(metadata.social_twitter)) {
throw Error("Twitter URL must be a valid Twitter URL format");
}
if (metadata.social_discord &&
!exports.METADATA_VALIDATION.URL_PATTERN.test(metadata.social_discord)) {
throw Error("Discord URL must be a valid URL");
}
if (metadata.social_telegram &&
!exports.METADATA_VALIDATION.SOCIAL_TELEGRAM_PATTERN.test(metadata.social_telegram)) {
throw Error("Telegram URL must be a valid Telegram URL format");
}
if (metadata.social_website &&
!exports.METADATA_VALIDATION.URL_PATTERN.test(metadata.social_website)) {
throw Error("Website URL must be a valid HTTP/HTTPS URL");
}
if (metadata.social_github &&
!exports.METADATA_VALIDATION.SOCIAL_GITHUB_PATTERN.test(metadata.social_github)) {
throw Error("GitHub URL must be a valid GitHub URL format");
}
// Check overall metadata size
const metadataSize = new TextEncoder().encode(JSON.stringify(metadata)).length;
if (metadataSize > exports.METADATA_VALIDATION.MAX_METADATA_SIZE) {
throw Error(`Metadata size exceeds maximum of ${exports.METADATA_VALIDATION.MAX_METADATA_SIZE} bytes`);
}
};
/**
* Convert Metadata object to URI using Pinata IPFS service
* This function tries to upload metadata to IPFS via Pinata API
* If Pinata fails, it falls back to a data URI
*
* @param metadata - The metadata object to convert
* @returns An IPFS URI or data URI representing the metadata
*/
const convertMetadataToURI = async (metadata) => {
// Validate metadata using the comprehensive validation function
validateMetadata(metadata);
// Check for Pinata API keys in environment variables
const pinataApiKey = process.env.PINATA_API_KEY;
const pinataSecretApiKey = process.env.PINATA_SECRET_API_KEY;
if (!pinataApiKey || !pinataSecretApiKey) {
throw Error("Missing required Pinata API credentials: PINATA_API_KEY and PINATA_SECRET_API_KEY environment variables must be configured");
}
// Try to use Pinata if API keys are available
if (pinataApiKey && pinataSecretApiKey) {
try {
// Prepare headers for Pinata API request
const headers = {
"Content-Type": "application/json",
pinata_api_key: pinataApiKey,
pinata_secret_api_key: pinataSecretApiKey,
};
// Upload metadata to Pinata using fetch
const response = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", {
method: "POST",
headers: headers,
body: JSON.stringify(metadata),
});
// Check if upload was successful
if (!response.ok) {
// Simple error with no stack trace
throw Error(`Pinata: ${response.statusText}`);
}
// Parse the response to get the IPFS hash
const data = await response.json();
const ipfsHash = data.IpfsHash;
// Validate IPFS hash format
if (!ipfsHash || typeof ipfsHash !== "string" || ipfsHash.length !== 46) {
throw Error("Invalid IPFS hash returned from Pinata");
}
// Return IPFS URI using the hash returned by Pinata
return `ipfs://${ipfsHash}`;
}
catch (pinataError) {
// Fallback silently to data URI without detailed error logs
// Just a simple one-line log
if (pinataError instanceof Error) {
console.log(`Pinata fallback: ${pinataError.message}`);
}
}
}
// Fallback: Use data URI when Pinata is not available or failed
// Convert metadata to data URI without verbose logging
const metadataJSON = JSON.stringify(metadata);
const base64Metadata = Buffer.from(metadataJSON).toString("base64");
const dataUri = `data:application/json;base64,${base64Metadata}`;
// Validate the data URI
if (!exports.METADATA_VALIDATION.DATA_URI_PATTERN.test(dataUri)) {
throw Error("Failed to generate valid data URI");
}
return dataUri;
};
exports.convertMetadataToURI = convertMetadataToURI;
/**
* Validates a URI string
* @param uri - The URI to validate
* @throws Error if validation fails
*/
const validateURI = (uri) => {
if (!uri) {
throw Error("URI is required");
}
// Check for data URI format
if (uri.startsWith("data:application/json;base64,")) {
if (!exports.METADATA_VALIDATION.DATA_URI_PATTERN.test(uri)) {
throw Error("Invalid data URI format");
}
return;
}
// Check for IPFS URI format
if (uri.startsWith("ipfs://")) {
const ipfsHash = uri.substring("ipfs://".length);
if (ipfsHash.length !== 46) {
throw Error("Invalid IPFS hash length in URI");
}
return;
}
// Check for HTTP/HTTPS URL format
if (uri.startsWith("http://") || uri.startsWith("https://")) {
if (!exports.METADATA_VALIDATION.URL_PATTERN.test(uri)) {
throw Error("Invalid HTTP/HTTPS URL format");
}
return;
}
throw Error(`Unsupported URI format: ${uri.substring(0, 20)}...`);
};
/**
* Convert URI back to Metadata object
* Supports both IPFS URIs and data URIs
*
* @param uri - The URI to convert (ipfs:// or data:application/json;base64,)
* @returns The metadata object
*/
const convertURItoMetadata = async (uri) => {
// Validate URI format
validateURI(uri);
try {
// Handle data URIs
if (uri.startsWith("data:application/json;base64,")) {
// Extract the base64 encoded data
const base64Data = uri.substring("data:application/json;base64,".length);
// Decode base64 to JSON string
const jsonString = Buffer.from(base64Data, "base64").toString("utf-8");
try {
// Parse JSON to object
const metadata = JSON.parse(jsonString);
// Validate the parsed metadata
validateMetadata(metadata);
return metadata;
}
catch (parseError) {
throw Error("Invalid JSON format in data URI");
}
}
// Handle IPFS URIs (ipfs://...)
if (uri.startsWith("ipfs://")) {
const ipfsHash = uri.substring("ipfs://".length);
// Determine which IPFS gateway to use - from env or default to public gateway
let gateway = process.env.IPFS_GATEWAY || "https://ipfs.io/ipfs/";
// Make sure gateway ends with /
if (!gateway.endsWith("/")) {
gateway += "/";
}
// Validate gateway URL
if (!exports.METADATA_VALIDATION.URL_PATTERN.test(gateway.replace(/\/$/, "") + "/test")) {
throw Error("Invalid IPFS gateway URL");
}
// Fetch metadata from IPFS using public gateway
const url = `${gateway}${ipfsHash}`;
const response = await fetch(url);
if (!response.ok) {
throw Error(`IPFS fetch failed: ${response.statusText}`);
}
try {
// Parse JSON response
const metadata = (await response.json());
// Validate the fetched metadata
validateMetadata(metadata);
return metadata;
}
catch (parseError) {
throw Error("Invalid JSON format in IPFS response");
}
}
// Handle HTTP/HTTPS URIs
if (uri.startsWith("http://") || uri.startsWith("https://")) {
const response = await fetch(uri);
if (!response.ok) {
throw Error(`HTTP fetch failed: ${response.statusText}`);
}
try {
// Parse JSON response
const metadata = (await response.json());
// Validate the fetched metadata
validateMetadata(metadata);
return metadata;
}
catch (parseError) {
throw Error("Invalid JSON format in HTTP response");
}
}
throw Error(`Unsupported URI format: ${uri.substring(0, 20)}...`);
}
catch (error) {
if (error instanceof Error) {
throw Error(`Failed to convert URI to metadata: ${error.message}`);
}
else {
throw Error("Unknown error converting URI to metadata");
}
}
};
exports.convertURItoMetadata = convertURItoMetadata;
/**
* Update the metadata URI of the vault
* @param vaultContract - The vault contract connected to signer
* @param metadata - The metadata object to be stored or directly URI
* @returns Transaction response
*/
async function setMetadata(vaultContract, metadata) {
if (!vaultContract) {
throw Error("Vault contract is required");
}
try {
// Convert metadata to URI (validation done inside convertMetadataToURI)
let metadataURI;
if (typeof metadata === "string") {
// Validate the URI
validateURI(metadata);
metadataURI = metadata;
}
else {
metadataURI = await (0, exports.convertMetadataToURI)(metadata);
}
console.log(`Using URI: ${metadataURI}`);
// Update the vault's metadata URI
return await (0, utils_1.executeContractMethod)(vaultContract, "updateMetadataURI", metadataURI);
}
catch (error) {
// Re-throw the error with a clean message and no stack trace
if (error instanceof Error) {
throw Error(error.message);
}
else {
throw Error("Unknown error updating metadata");
}
}
}