diff --git a/.changeset/clean-knives-teach.md b/.changeset/clean-knives-teach.md new file mode 100644 index 000000000..7a929f022 --- /dev/null +++ b/.changeset/clean-knives-teach.md @@ -0,0 +1,7 @@ +--- +"@onflow/fcl-core": minor +"@onflow/typedefs": minor +"@onflow/sdk": minor +--- + +Refactored fcl-core package to TypeScript diff --git a/.changeset/fifty-seahorses-act.md b/.changeset/fifty-seahorses-act.md new file mode 100644 index 000000000..24e32085a --- /dev/null +++ b/.changeset/fifty-seahorses-act.md @@ -0,0 +1,8 @@ +--- +"@onflow/fcl-core": minor +"@onflow/typedefs": minor +"@onflow/fcl": minor +"@onflow/sdk": minor +--- + +Refactored fcl package to TypeScript diff --git a/.changeset/lemon-toes-warn.md b/.changeset/lemon-toes-warn.md new file mode 100644 index 000000000..5975fcbe7 --- /dev/null +++ b/.changeset/lemon-toes-warn.md @@ -0,0 +1,8 @@ +--- +"@onflow/fcl-react-native": minor +"@onflow/fcl-core": minor +"@onflow/typedefs": minor +"@onflow/sdk": minor +--- + +Refactored fcl-react-native package to TypeScript diff --git a/.changeset/strong-onions-behave.md b/.changeset/strong-onions-behave.md new file mode 100644 index 000000000..74480ce61 --- /dev/null +++ b/.changeset/strong-onions-behave.md @@ -0,0 +1,13 @@ +--- +"@onflow/fcl-ethereum-provider": minor +"@onflow/transport-http": minor +"@onflow/util-logger": minor +"@onflow/fcl-core": minor +"@onflow/typedefs": minor +"@onflow/util-rpc": minor +"@onflow/fcl-wc": minor +"@onflow/fcl": minor +"@onflow/sdk": minor +--- + +Converted enums to template literals diff --git a/.gitignore b/.gitignore index 2cf67e10a..c34a124c3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ todo.md .vscode/* # type declarations -packages/*/types/ \ No newline at end of file +packages/*/types/ + +# generated documentation +/docs-generator/output/* \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index a08601d5b..03e07508e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ README.md *.md *.mdx -packages/protobuf/src/generated/ \ No newline at end of file +packages/protobuf/src/generated/ +**/*.hbs \ No newline at end of file diff --git a/docs-generator/README.md b/docs-generator/README.md new file mode 100644 index 000000000..a60e753e1 --- /dev/null +++ b/docs-generator/README.md @@ -0,0 +1,177 @@ +# Docs Generator + +This directory contains scripts to generate documentation for Flow Client Library (FCL) packages. + +## Overview + +The documentation generator creates Markdown files for Docusaurus v2 websites. It automatically extracts TypeScript function signatures, parameter types, return types, and JSDoc comments to create comprehensive API documentation. + +## Directory Structure + +- `generate-docs.js` - Main script for generating documentation for a single package +- `generate-all-docs.js` - Script to generate documentation for all packages with the generate-docs script +- `templates/` - Handlebars templates for generating documentation pages +- `output/` - Where generated files are created +- `generators/` - Function utils to generate pages from templates and data + +## Features + +- Automatic discovery of packages with generate-docs scripts +- Support for JSDoc comments including function descriptions, parameter info, and usage examples +- Handlebars templates for easy customization of output format +- Consistent documentation structure across all packages +- Package index page listing all available packages +- Core types documentation from the @onflow/typedefs package + +### JSDoc Support + +The documentation generator extracts information from JSDoc comments in code. JSDoc comments can be added to improve the generated documentation: + +```javascript +/** + * This description will be used in the documentation. + * + * @param {string} param1 - This description will be used for the parameter + * @returns {number} This description will be used for the return value + * @example + * // This example will be used in the documentation + * const result = myFunction("test") + */ +export function myFunction(param1) { + // ... +} +``` + +## Usage + +### For Individual Packages + +Each package that needs documentation should include a `generate-docs` script in its `package.json`: + +```json +{ + "scripts": { + "generate-docs": "node ../../docs-generator/generate-docs.js" + } +} +``` + +To generate documentation for a single package, run: + +```bash +cd packages/ +npm run generate-docs +``` + +### For All Packages + +To generate documentation for all packages that have a `generate-docs` script: + +```bash +npm run generate-docs-all +``` + +This will: +1. Find all packages with a `generate-docs` script +2. Run the script for each package +3. Generate documentation in the `/docs-generator/output/packages-docs/` directory +4. Generate core types documentation in `/docs-generator/output/packages-docs/types/index.md` + +## Output Structure + +The generated documentation follows this structure: + +``` +/docs-generator/output/ + └── packages-docs/ # Main folder containing + ├── package-a/ # Documentation for package-a + │ ├── index.md # Main package page with installation instructions and API + │ ├── functionName1.md + │ ├── functionName2.md + │ └── ... + ├── package-b/ + ├── types/ + │ └── index.md # Type definitions page with interfaces, type aliases, and enums + └── index.md # List contents of the folder +``` + +Each package has a main page that includes: +- Package overview +- Installation instructions +- API reference with links to individual function documentation + +### Auto-generation Notes + +All generated files are automatically generated from the source code of FCL packages and are ignored by git (except this README). +Do not modify these files directly as they will be overwritten when documentation is regenerated. + +Instead: +- Update the JSDoc comments in the source code +- Customize the templates in `docs-generator/templates/` +- Create a `docs-generator.config.js` file in the package root for custom content + +## Customizing Templates + +You can customize the generated documentation by editing the Handlebars templates in the `templates/` directory. + +### Custom Package Documentation + +Packages can provide custom documentation content by creating a `docs-generator.config.js` file in the package root directory. The following customizations are supported: + +```js +module.exports = { + customData: { + displayName: `Custom Package Reference`, // Used for Docusaurus sidebar title + sections: { + overview: ``, // Custom overview section + requirements: ``, // Custom requirements section + importing: ``, // Custom importing section + }, + extra: ``, // Additional content + }, +}; +``` + +All properties in the configuration are optional. If a property is not specified, default values will be used. + +## Adding Documentation to a New Package + +To add documentation generation to a new package: + +1. Add the generate-docs script to the package's `package.json`: + +```json +{ + "scripts": { + "generate-docs": "node ../../docs-generator/generate-docs.js" + } +} +``` + +2. Ensure the code has proper JSDoc comments for better documentation. + +3. Run the generate-docs script to test it. + +This package will now be included when running the `generate-docs-all` command. + +## Core Types Documentation + +The generator also creates documentation for all types, interfaces, and enums exported from the `@onflow/typedefs` package. This documentation is generated every time you run the generate-docs script for any package, ensuring that the types documentation is always up-to-date. + +The types documentation in the `types` directory includes: + +- **Interfaces** - Documented with their properties and methods +- **Types** - Documented with their underlying types +- **Enums** - Documented with all their members and values + +All type documentation includes JSDoc descriptions when available. + +## Integration with Documentation Projects + +After generating documentation, copy the `output/packages-docs` directory to the documentation project. This will maintain the folder structure and allow the documentation build system to process the files. + +## Notes + +- Avoid relative path linking outside of packages-docs folder to avoid docusaurus linking problems. Use only packages-docs relative links or absolute paths. +- If adding an example to a jsdoc, avoid adding backticks, it will be directly embedded into typescript backticks on pages generation. +- Don't use multiple @returns in jsdocs, they are not supported. diff --git a/docs-generator/generate-all-docs.js b/docs-generator/generate-all-docs.js new file mode 100755 index 000000000..21c1eebbe --- /dev/null +++ b/docs-generator/generate-all-docs.js @@ -0,0 +1,64 @@ +const fs = require("fs") +const path = require("path") +const {execSync} = require("child_process") + +async function main() { + try { + // Get packages source directory + const sourcePackagesDir = path.resolve(__dirname, "../packages") + // Find packages with generate-docs script + console.log(`Scanning for packages in ${sourcePackagesDir}`) + const packages = + fs.readdirSync(sourcePackagesDir).filter(name => { + try { + const itemPath = path.join(sourcePackagesDir, name) + // Check if it's a directory first + if (!fs.statSync(itemPath).isDirectory()) { + return false + } + + const packageJsonPath = path.join( + sourcePackagesDir, + name, + "package.json" + ) + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, "utf8") + ) + return packageJson.scripts && packageJson.scripts["generate-docs"] + } catch (error) { + console.warn(`Error checking package ${name}: ${error.message}`) + return false + } + }) || [] + if (packages.length === 0) { + console.warn("No packages with generate-docs script were found.") + return + } + console.log(`Found ${packages.length} packages with generate-docs script:`) + packages.forEach(pkg => console.log(`- ${pkg}`)) + + // Navigate to the package directory and run the generate-docs script + for (const pkg of packages) { + const pkgDir = path.join(sourcePackagesDir, pkg) + execSync(`cd ${pkgDir} && npm run generate-docs`, { + stdio: "inherit", + env: {...process.env}, + }) + console.log("") + } + + // Report results + console.log(`All docs correctly generated.`) + } catch (error) { + console.error("Error:") + console.error(error.message || error) + process.exit(1) + } +} + +main().catch(error => { + console.error("Unhandled error:") + console.error(error.message || error) + process.exit(1) +}) diff --git a/docs-generator/generate-docs.js b/docs-generator/generate-docs.js new file mode 100755 index 000000000..27ef0d12a --- /dev/null +++ b/docs-generator/generate-docs.js @@ -0,0 +1,133 @@ +const fs = require("fs") +const path = require("path") +const {Project} = require("ts-morph") +const Handlebars = require("handlebars") +const { + generateRootPage, + generatePackagePage, + generateFunctionPage, + generateTypesPage, + generateNamespacePage, +} = require("./generators") +const { + discoverWorkspacePackages, + extractExportsFromEntryFile, +} = require("./utils") + +async function main() { + try { + // Extract package name from the name field of the package where the command is run (@onflow/fcl -> fcl) + const packageJson = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), "package.json"), "utf8") + ) + const packageName = packageJson.name.split("/").pop() + console.log(`Generating docs for ${packageName}...`) + + // Get the entry file from package.json source field + const entryFile = packageJson.source || "" + const ENTRY_FILE_PATH = path.resolve(process.cwd(), entryFile) + + // Configuration with updated directory structure + const TEMPLATES_DIR = path.resolve(__dirname, "./templates") + const OUTPUT_DIR = path.resolve(__dirname, "./output") + + const ROOT_DIR = path.join(OUTPUT_DIR, "packages-docs") + const PACKAGE_DIR = path.join(ROOT_DIR, packageName) + const TYPES_DIR = path.join(ROOT_DIR, "types") + + // Ensure output directories exist + await fs.promises.mkdir(OUTPUT_DIR, {recursive: true}) + await fs.promises.mkdir(ROOT_DIR, {recursive: true}) + // Clean existing output directory content for the package before creating its folder + await fs.promises.rm(PACKAGE_DIR, {recursive: true, force: true}) + await fs.promises.mkdir(PACKAGE_DIR, {recursive: true}) + await fs.promises.mkdir(TYPES_DIR, {recursive: true}) + + // Handlebars templates to be used for generating the docs + const templates = { + root: Handlebars.compile( + fs.readFileSync(path.join(TEMPLATES_DIR, "root.hbs"), "utf8") + ), + package: Handlebars.compile( + fs.readFileSync(path.join(TEMPLATES_DIR, "package.hbs"), "utf8") + ), + function: Handlebars.compile( + fs.readFileSync(path.join(TEMPLATES_DIR, "function.hbs"), "utf8") + ), + types: Handlebars.compile( + fs.readFileSync(path.join(TEMPLATES_DIR, "types.hbs"), "utf8") + ), + namespace: Handlebars.compile( + fs.readFileSync(path.join(TEMPLATES_DIR, "namespace.hbs"), "utf8") + ), + } + + // Initialize ts-morph project and add source files + const project = new Project({ + skipAddingFilesFromTsConfig: true, + }) + // Add the entry file + project.addSourceFileAtPath(ENTRY_FILE_PATH) + // Automatically discover and add all workspace packages for resolving imports + const workspacePackagePaths = discoverWorkspacePackages() + for (const packagePath of workspacePackagePaths) { + try { + project.addSourceFilesAtPaths(packagePath) + } catch (e) { + console.warn( + `Could not add source files from ${packagePath}: ${e.message}` + ) + } + } + + // Get the entry source file and extract exports from it + const entrySourceFile = project.getSourceFile(ENTRY_FILE_PATH) + const {functions, namespaces} = extractExportsFromEntryFile(entrySourceFile) + console.log( + `Found ${functions.length} functions and ${namespaces.length} namespaces` + ) + + // Collect all namespace functions for the package index + let allNamespaceFunctions = [] + namespaces.forEach(namespace => { + allNamespaceFunctions = allNamespaceFunctions.concat(namespace.functions) + }) + + // Generate documentation + generateRootPage(templates, ROOT_DIR, packageName) + generatePackagePage( + templates, + PACKAGE_DIR, + packageName, + functions, + namespaces, + allNamespaceFunctions + ) + + // Generate function pages for regular functions only + functions.forEach(func => { + generateFunctionPage(templates, PACKAGE_DIR, packageName, func) + }) + + // Generate single namespace pages (no individual function pages) + namespaces.forEach(namespace => { + generateNamespacePage(templates, PACKAGE_DIR, packageName, namespace) + }) + + // Generate the types documentation + generateTypesPage(templates, TYPES_DIR) + + console.log(`Docs generated correctly for ${packageName}.`) + return true + } catch (error) { + console.error("Error generating docs:") + console.error(error.message) + return false + } +} + +main().catch(error => { + console.error("Unhandled error:") + console.error(error.message || error) + process.exit(1) +}) diff --git a/docs-generator/generators/generate-function-page.js b/docs-generator/generators/generate-function-page.js new file mode 100644 index 000000000..0010da81c --- /dev/null +++ b/docs-generator/generators/generate-function-page.js @@ -0,0 +1,745 @@ +const path = require("path") +const {Project} = require("ts-morph") +const fs = require("fs") +const {generatePage, getFirstWord} = require("./utils") + +// Basic primitive types that don't need definitions +const PRIMITIVE_TYPES = new Set([ + "string", + "number", + "boolean", + "object", + "any", + "void", + "unknown", + "never", + "null", + "undefined", +]) + +// Cache for type definitions and core types to avoid repeated lookups +const typeCache = new Map() +const coreTypesCache = new Set() +let hasLoadedCoreTypes = false + +// Function to escape MDX-sensitive characters in example code +function escapeMDXCharacters(text) { + if (!text) return text + + // Handle already escaped curly braces by converting them to inline code + // This pattern matches things like A.\{AccountAddress\}.\{ContractName\}.\{EventName\} + text = text.replace(/A\.\\{[^}]+\\}\.\\{[^}]+\\}\.\\{[^}]+\\}/g, match => { + // Remove the backslashes and wrap in backticks + const cleanMatch = match.replace(/\\/g, "") + return `\`${cleanMatch}\`` + }) + + // Handle other patterns of escaped curly braces by converting to inline code + text = text.replace(/\\{[^}]+\\}/g, match => { + // Remove the backslashes and wrap in backticks + const cleanMatch = match.replace(/\\/g, "") + return `\`${cleanMatch}\`` + }) + + // Split text by both multi-line code blocks (triple backticks) and inline code (single backticks) + // This regex captures both patterns while preserving them + const parts = text.split(/(```[\s\S]*?```|`[^`\n]*`)/g) + + return parts + .map((part, index) => { + // Check if this part is a code block (either multi-line or inline) + const isCodeBlock = + part.startsWith("```") || + (part.startsWith("`") && part.endsWith("`") && !part.includes("\n")) + + if (isCodeBlock) { + // Don't escape anything inside code blocks (both multi-line and inline) + return part + } else { + // Escape curly braces only outside code blocks + return part.replace(/(? { + sourceFile.getInterfaces().forEach(iface => { + if (iface.isExported()) coreTypesCache.add(iface.getName()) + }) + sourceFile.getTypeAliases().forEach(typeAlias => { + if (typeAlias.isExported()) coreTypesCache.add(typeAlias.getName()) + }) + sourceFile.getEnums().forEach(enumDef => { + if (enumDef.isExported()) coreTypesCache.add(enumDef.getName()) + }) + }) + + hasLoadedCoreTypes = true + return coreTypesCache + } catch (error) { + console.warn(`Error extracting core types: ${error.message}`) + return new Set() + } +} + +function extractTypeName(fullType) { + if (!fullType) return fullType + + // Clean up import references and comments + let cleanType = fullType + .replace(/import\([^)]+\)\./g, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .trim() + + // For union types, preserve the structure + if (cleanType.includes("|")) { + return cleanType + } + + // For simple types without complex structures + if ( + !cleanType.includes("Promise<") && + !cleanType.includes("=>") && + !cleanType.includes("{") + ) { + return cleanType + } + + // Handle Promise types + if (cleanType.startsWith("Promise<")) { + const innerType = cleanType.match(/Promise<(.+)>/) + if (innerType && innerType[1]) { + return `Promise<${extractTypeName(innerType[1])}>` + } + } + + // Handle function types + if (cleanType.includes("=>")) { + return cleanType + } + + // Handle array types + if (cleanType.endsWith("[]")) { + return `${extractTypeName(cleanType.slice(0, -2))}[]` + } + + // Handle complex objects - return as is for now + if (cleanType.includes("{") && cleanType.includes("}")) { + return cleanType + } + + return cleanType +} + +// Check if a type name exists in the typedefs package +function isTypeInTypedefs(typeName, coreTypes) { + // Handle Promise - extract the inner type + if (typeName.startsWith("Promise<") && typeName.endsWith(">")) { + const innerType = typeName.slice(8, -1).trim() // Remove Promise< and > + return coreTypes.has(innerType) + } + + // Handle Array types - extract the base type + if (typeName.endsWith("[]")) { + const baseType = typeName.slice(0, -2).trim() + return coreTypes.has(baseType) + } + + // Handle union types - check if any part is in typedefs + if (typeName.includes("|")) { + const types = typeName.split("|").map(t => t.trim()) + return types.some(t => coreTypes.has(t)) + } + + return coreTypes.has(typeName) +} + +// Check if a type is non-primitive (interface, type alias, or arrow function) +function isNonPrimitiveType(typeString) { + if (!typeString || PRIMITIVE_TYPES.has(typeString)) { + return false + } + + // Remove whitespace for better pattern matching + const cleanType = typeString.trim() + + // Function types (arrow functions) + if (cleanType.includes("=>")) { + return true + } + + // Object types with properties (inline object types) + if (cleanType.includes("{") && cleanType.includes(":")) { + return true + } + + // Complex union types (more than just primitive types) + if (cleanType.includes("|")) { + const unionTypes = cleanType.split("|").map(t => t.trim()) + // If any part of the union is not primitive, show definition + const hasNonPrimitive = unionTypes.some( + t => + !PRIMITIVE_TYPES.has(t) && + !t.match(/^(null|undefined)$/) && + (t.match(/^[A-Z][a-zA-Z0-9]*$/) || t.includes("=>") || t.includes("{")) + ) + if (hasNonPrimitive) return true + } + + // Generic types like Promise, Array, etc. + if (cleanType.includes("<") && cleanType.includes(">")) { + // Extract the inner type from generics + const genericMatch = cleanType.match( + /^([A-Za-z][a-zA-Z0-9]*)<(.+)>(\[\])?$/ + ) + if (genericMatch) { + const [, outerType, innerType] = genericMatch + + // If outer type is Promise, Array, etc., check if inner type is non-primitive + if (["Promise", "Array"].includes(outerType)) { + return isNonPrimitiveType(innerType.trim()) + } + + // For other generic types, if the outer type starts with uppercase, it's likely custom + if (/^[A-Z]/.test(outerType)) { + return true + } + } + return true // Any other generic type is likely non-primitive + } + + // Tuple types + if ( + cleanType.startsWith("[") && + cleanType.endsWith("]") && + cleanType.includes(",") + ) { + return true + } + + // Mapped types or conditional types + if ( + cleanType.includes("keyof") || + cleanType.includes("extends") || + cleanType.includes("infer") + ) { + return true + } + + // Array types with custom base types + if (cleanType.endsWith("[]")) { + const baseType = cleanType.slice(0, -2).trim() + return isNonPrimitiveType(baseType) + } + + // Custom type names (PascalCase starting with uppercase) + if (/^[A-Z][a-zA-Z0-9]*$/.test(cleanType)) { + return true + } + + // Type names with namespaces (e.g., FCL.SomeType) + if (/^[A-Z][a-zA-Z0-9]*\.[A-Z][a-zA-Z0-9]*$/.test(cleanType)) { + return true + } + + // Function types in different formats + if (cleanType.match(/^\(.+\)\s*=>/)) { + return true + } + + // Constructor types + if (cleanType.startsWith("new ") && cleanType.includes("=>")) { + return true + } + + return false +} + +function extractTypeDefinition(sourceFile, node) { + if (!node) return null + + const text = sourceFile.getFullText().substring(node.getPos(), node.getEnd()) + + // Remove comments and extra whitespace + return text + .replace(/^[\r\n\s]+/, "") // Trim leading whitespace + .replace(/[\r\n\s]+$/, "") // Trim trailing whitespace + .replace(/\/\*\*[\s\S]*?\*\//g, "") // Remove JSDoc comments + .replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments + .replace(/\/\/.*$/gm, "") // Remove single-line comments + .replace(/\n\s*\n+/g, "\n") // Replace multiple blank lines with a single one + .trim() +} + +function findTypeInFile(sourceFile, typeName) { + // Check for interface + const iface = sourceFile.getInterface(typeName) + if (iface) return extractTypeDefinition(sourceFile, iface) + // Check for type alias + const typeAlias = sourceFile.getTypeAlias(typeName) + if (typeAlias) return extractTypeDefinition(sourceFile, typeAlias) + // Check for enum + const enumDef = sourceFile.getEnum(typeName) + if (enumDef) return extractTypeDefinition(sourceFile, enumDef) + + return null +} + +function getTypeDefinition(typeName, packageName, sourceFilePath) { + if (!typeName || PRIMITIVE_TYPES.has(typeName)) { + return null + } + + // Handle Promise and array types - extract base type + let baseTypeName = typeName + if (baseTypeName.startsWith("Promise<")) { + const match = baseTypeName.match(/Promise<(.+)>/) + if (match) baseTypeName = match[1] + } + if (baseTypeName.endsWith("[]")) { + baseTypeName = baseTypeName.slice(0, -2) + } + + // Check cache first + const cacheKey = `${packageName}:${baseTypeName}` + if (typeCache.has(cacheKey)) { + return typeCache.get(cacheKey) + } + + let definition = null + + try { + // Create a new project for type searching to avoid conflicts + const project = new Project({skipAddingFilesFromTsConfig: true}) + + // First check source file if provided + if (sourceFilePath) { + const fullSourcePath = path.resolve(process.cwd(), "../", sourceFilePath) + if (fs.existsSync(fullSourcePath)) { + const sourceFile = project.addSourceFileAtPath(fullSourcePath) + definition = findTypeInFile(sourceFile, baseTypeName) + + // If not found in the source file, check its imports and re-exports + if (!definition) { + definition = searchTypeInImports(sourceFile, baseTypeName, project) + } + } + } + + // If not found, search package src directory + if (!definition) { + const packageSrcDir = path.resolve( + process.cwd(), + "../", + packageName, + "src" + ) + if (fs.existsSync(packageSrcDir)) { + // Add all TypeScript files in the package + project.addSourceFilesAtPaths(`${packageSrcDir}/**/*.ts`) + + // Search through all source files in the package + for (const sourceFile of project.getSourceFiles()) { + definition = findTypeInFile(sourceFile, baseTypeName) + if (definition) break + } + } + } + + // If still not found, search in common Flow workspace packages + if (!definition) { + definition = searchTypeInWorkspacePackages(baseTypeName, project) + } + + // Cache the result + typeCache.set(cacheKey, definition) + return definition + } catch (error) { + console.warn( + `Error getting type definition for ${typeName}: ${error.message}` + ) + return null + } +} + +// Helper function to search for types in imports and re-exports +function searchTypeInImports(sourceFile, typeName, project) { + try { + // Check import declarations + const importDeclarations = sourceFile.getImportDeclarations() + for (const importDecl of importDeclarations) { + const namedImports = importDecl.getNamedImports() + const hasImport = namedImports.some( + namedImport => namedImport.getName() === typeName + ) + + if (hasImport) { + const moduleSpecifier = importDecl.getModuleSpecifier() + if (moduleSpecifier) { + const moduleSpecValue = moduleSpecifier.getLiteralValue() + const resolvedFile = resolveImportPath( + sourceFile, + moduleSpecValue, + project + ) + if (resolvedFile) { + const definition = findTypeInFile(resolvedFile, typeName) + if (definition) return definition + } + } + } + } + + // Check export declarations (re-exports) + const exportDeclarations = sourceFile.getExportDeclarations() + for (const exportDecl of exportDeclarations) { + const namedExports = exportDecl.getNamedExports() + const hasExport = namedExports.some( + namedExport => namedExport.getName() === typeName + ) + + if (hasExport) { + const moduleSpecifier = exportDecl.getModuleSpecifier() + if (moduleSpecifier) { + const moduleSpecValue = moduleSpecifier.getLiteralValue() + const resolvedFile = resolveImportPath( + sourceFile, + moduleSpecValue, + project + ) + if (resolvedFile) { + const definition = findTypeInFile(resolvedFile, typeName) + if (definition) return definition + // Recursively search in the resolved file's imports + const recursiveDefinition = searchTypeInImports( + resolvedFile, + typeName, + project + ) + if (recursiveDefinition) return recursiveDefinition + } + } + } + } + + return null + } catch (error) { + console.warn(`Error searching type in imports: ${error.message}`) + return null + } +} + +// Helper function to resolve import paths +function resolveImportPath(sourceFile, moduleSpecifier, project) { + try { + // Handle @onflow/ package imports + if (moduleSpecifier.startsWith("@onflow/")) { + const packageName = moduleSpecifier.replace("@onflow/", "") + const packageDir = path.resolve(process.cwd(), "../", packageName) + const packageJsonPath = path.join(packageDir, "package.json") + + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) + const entryFile = + packageJson.source || packageJson.main || "src/index.ts" + const entryFilePath = path.resolve(packageDir, entryFile) + + if (fs.existsSync(entryFilePath)) { + let targetFile = project.getSourceFile(entryFilePath) + if (!targetFile) { + targetFile = project.addSourceFileAtPath(entryFilePath) + } + return targetFile + } + } + } + + // Handle relative imports + else if ( + moduleSpecifier.startsWith("./") || + moduleSpecifier.startsWith("../") + ) { + const sourceDir = path.dirname(sourceFile.getFilePath()) + const resolvedPath = path.resolve(sourceDir, moduleSpecifier) + + // Try different extensions + const possiblePaths = [ + resolvedPath + ".ts", + resolvedPath + ".d.ts", + resolvedPath + "/index.ts", + resolvedPath + "/index.d.ts", + ] + + for (const possiblePath of possiblePaths) { + if (fs.existsSync(possiblePath)) { + let targetFile = project.getSourceFile(possiblePath) + if (!targetFile) { + targetFile = project.addSourceFileAtPath(possiblePath) + } + return targetFile + } + } + } + + return null + } catch (error) { + console.warn( + `Error resolving import path ${moduleSpecifier}: ${error.message}` + ) + return null + } +} + +// Helper function to search for types in common workspace packages +function searchTypeInWorkspacePackages(typeName, project) { + try { + const workspacePackages = ["sdk", "typedefs", "fcl-core", "types"] + + for (const packageName of workspacePackages) { + const packageSrcDir = path.resolve( + process.cwd(), + "../", + packageName, + "src" + ) + if (fs.existsSync(packageSrcDir)) { + try { + project.addSourceFilesAtPaths(`${packageSrcDir}/**/*.ts`) + + for (const sourceFile of project.getSourceFiles()) { + if (sourceFile.getFilePath().includes(packageName)) { + const definition = findTypeInFile(sourceFile, typeName) + if (definition) return definition + } + } + } catch (e) { + // Continue to next package if this one fails + console.warn( + `Could not search in package ${packageName}: ${e.message}` + ) + } + } + } + + return null + } catch (error) { + console.warn(`Error searching workspace packages: ${error.message}`) + return null + } +} + +function processTypeForDisplay( + typeString, + coreTypes, + packageName, + sourceFilePath +) { + if (!typeString) { + return { + displayType: typeString, + hasLink: false, + linkedType: null, + typeDefinition: null, + } + } + + const extractedType = extractTypeName(typeString) + + // Handle union types specially + if (extractedType.includes("|")) { + const unionTypes = extractedType.split("|").map(t => t.trim()) + let hasAnyLink = false + + const processedTypes = unionTypes.map(type => { + if (isTypeInTypedefs(type, coreTypes)) { + hasAnyLink = true + let linkType = type + let linkFragment = type.toLowerCase() + + // Handle Promise - link to the inner type + if (type.startsWith("Promise<") && type.endsWith(">")) { + const innerType = type.slice(8, -1).trim() + linkFragment = innerType.toLowerCase() + } + + // Handle Array types + if (type.endsWith("[]")) { + const baseType = type.slice(0, -2).trim() + linkFragment = baseType.toLowerCase() + } + + return `[\`${type}\`](../types#${linkFragment})` + } else { + return `\`${type}\`` + } + }) + + if (hasAnyLink) { + return { + displayType: extractedType, + hasLink: true, + linkedType: processedTypes.join(" | "), + typeDefinition: null, + } + } + } + + // Check if type exists in typedefs package + if (isTypeInTypedefs(extractedType, coreTypes)) { + let linkType = extractedType + let linkFragment = extractedType.toLowerCase() + + // Handle Promise - link to the inner type + if (extractedType.startsWith("Promise<") && extractedType.endsWith(">")) { + const innerType = extractedType.slice(8, -1).trim() + linkFragment = innerType.toLowerCase() + } + + // Handle Array types + if (extractedType.endsWith("[]")) { + const baseType = extractedType.slice(0, -2).trim() + linkFragment = baseType.toLowerCase() + } + + // Handle TypeScript utility types - link to the inner type + const utilityTypeMatch = extractedType.match( + /^(Partial|Required|Omit|Pick|Record|Exclude|Extract|NonNullable|ReturnType|Parameters|ConstructorParameters|InstanceType|ThisParameterType|OmitThisParameter|ThisType)<(.+)>$/ + ) + if (utilityTypeMatch) { + const [, utilityType, innerType] = utilityTypeMatch + const firstTypeParam = innerType.split(",")[0].trim() + linkFragment = firstTypeParam.toLowerCase() + } + + return { + displayType: extractedType, + hasLink: true, + linkedType: `[\`${extractedType}\`](../types#${linkFragment})`, + typeDefinition: null, + } + } + + // Check if it's a non-primitive type that should have a definition shown + if (isNonPrimitiveType(extractedType)) { + const typeDefinition = getTypeDefinition( + extractedType, + packageName, + sourceFilePath + ) + + // If we found a definition, use it + if (typeDefinition) { + return { + displayType: extractedType, + hasLink: false, + linkedType: null, + typeDefinition: typeDefinition, + } + } + + // If no definition found but it's clearly a function type, show it anyway + if (extractedType.includes("=>")) { + return { + displayType: extractedType, + hasLink: false, + linkedType: null, + typeDefinition: extractedType, + } + } + + // If no definition found but it looks like an interface/type name, + // don't show a fallback message - just show nothing + if (/^[A-Z][a-zA-Z0-9]*$/.test(extractedType)) { + return { + displayType: extractedType, + hasLink: false, + linkedType: null, + typeDefinition: null, + } + } + + // For other complex types, show them as type definitions + return { + displayType: extractedType, + hasLink: false, + linkedType: null, + typeDefinition: extractedType, + } + } + + // For primitive types or simple types, just show the type name + return { + displayType: extractedType, + hasLink: false, + linkedType: null, + typeDefinition: null, + } +} + +function generateFunctionPage(templates, outputDir, packageName, func) { + const coreTypes = extractCoreTypes() + + // Escape MDX characters in description + if (func.description) { + func.description = escapeMDXCharacters(func.description) + } + + // Process parameters + func.parameters = func.parameters.map(param => { + const typeInfo = processTypeForDisplay( + param.type, + coreTypes, + packageName, + func.sourceFilePath + ) + + // Escape MDX characters in parameter description + const description = param.description + ? escapeMDXCharacters(param.description) + : param.description + + return { + ...param, + type: typeInfo.displayType, + description, + linkedType: typeInfo.linkedType, + hasLink: typeInfo.hasLink, + typeDefinition: typeInfo.typeDefinition, + } + }) + + // Process return type + const returnTypeInfo = processTypeForDisplay( + func.returnType, + coreTypes, + packageName, + func.sourceFilePath + ) + + func.returnType = returnTypeInfo.displayType + func.returnHasLink = returnTypeInfo.hasLink + func.linkedType = returnTypeInfo.linkedType + func.returnTypeDefinition = returnTypeInfo.typeDefinition + + // Generate the page directly in the package folder instead of in a reference subfolder + const filename = func.name.charAt(0).toLowerCase() + func.name.slice(1) + generatePage(templates, "function", path.join(outputDir, `${filename}.md`), { + ...func, + packageName, + packageFirstWord: getFirstWord(packageName), + }) +} + +module.exports = {generateFunctionPage} diff --git a/docs-generator/generators/generate-namespace-page.js b/docs-generator/generators/generate-namespace-page.js new file mode 100644 index 000000000..7fbeb181b --- /dev/null +++ b/docs-generator/generators/generate-namespace-page.js @@ -0,0 +1,471 @@ +const path = require("path") +const fs = require("fs") +const {Project} = require("ts-morph") +const {generatePage, getFirstWord} = require("./utils") + +// Basic primitive types that don't need definitions +const PRIMITIVE_TYPES = new Set([ + "string", + "number", + "boolean", + "object", + "any", + "void", + "unknown", + "never", + "null", + "undefined", +]) + +const typeCache = new Map() +const coreTypesCache = new Set() +let hasLoadedCoreTypes = false + +// Function to escape MDX-sensitive characters in example code +function escapeMDXCharacters(text) { + if (!text) return text + + // Handle already escaped curly braces by converting them to inline code + // This pattern matches things like A.\{AccountAddress\}.\{ContractName\}.\{EventName\} + text = text.replace(/A\.\\{[^}]+\\}\.\\{[^}]+\\}\.\\{[^}]+\\}/g, match => { + // Remove the backslashes and wrap in backticks + const cleanMatch = match.replace(/\\/g, "") + return `\`${cleanMatch}\`` + }) + + // Handle other patterns of escaped curly braces by converting to inline code + text = text.replace(/\\{[^}]+\\}/g, match => { + // Remove the backslashes and wrap in backticks + const cleanMatch = match.replace(/\\/g, "") + return `\`${cleanMatch}\`` + }) + + // Split text by both multi-line code blocks (triple backticks) and inline code (single backticks) + // This regex captures both patterns while preserving them + const parts = text.split(/(```[\s\S]*?```|`[^`\n]*`)/g) + + return parts + .map((part, index) => { + // Check if this part is a code block (either multi-line or inline) + const isCodeBlock = + part.startsWith("```") || + (part.startsWith("`") && part.endsWith("`") && !part.includes("\n")) + + if (isCodeBlock) { + // Don't escape anything inside code blocks (both multi-line and inline) + return part + } else { + // Escape curly braces only outside code blocks + return part.replace(/(? { + sourceFile.getInterfaces().forEach(iface => { + if (iface.isExported()) coreTypesCache.add(iface.getName()) + }) + sourceFile.getTypeAliases().forEach(typeAlias => { + if (typeAlias.isExported()) coreTypesCache.add(typeAlias.getName()) + }) + sourceFile.getEnums().forEach(enumDef => { + if (enumDef.isExported()) coreTypesCache.add(enumDef.getName()) + }) + }) + + hasLoadedCoreTypes = true + return coreTypesCache + } catch (error) { + console.warn(`Error extracting core types: ${error.message}`) + return new Set() + } +} + +function extractTypeName(fullType) { + if (!fullType) return fullType + + // Clean up import references and comments + let cleanType = fullType + .replace(/import\([^)]+\)\./g, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .trim() + + // For union types, preserve the structure + if (cleanType.includes("|")) { + return cleanType + } + + // For simple types without complex structures + if ( + !cleanType.includes("Promise<") && + !cleanType.includes("=>") && + !cleanType.includes("{") + ) { + return cleanType + } + + // Handle Promise types + if (cleanType.startsWith("Promise<")) { + const innerType = cleanType.match(/Promise<(.+)>/) + if (innerType && innerType[1]) { + return `Promise<${extractTypeName(innerType[1])}>` + } + } + + // Handle function types + if (cleanType.includes("=>")) { + return cleanType + } + + // Handle array types + if (cleanType.endsWith("[]")) { + return `${extractTypeName(cleanType.slice(0, -2))}[]` + } + + // Handle complex objects - return as is for now + if (cleanType.includes("{") && cleanType.includes("}")) { + return cleanType + } + + return cleanType +} + +// Check if a type name exists in the typedefs package +function isTypeInTypedefs(typeName, coreTypes) { + // Handle Promise - extract the inner type + if (typeName.startsWith("Promise<") && typeName.endsWith(">")) { + const innerType = typeName.slice(8, -1).trim() // Remove Promise< and > + return coreTypes.has(innerType) + } + + // Handle Array types - extract the base type + if (typeName.endsWith("[]")) { + const baseType = typeName.slice(0, -2).trim() + return coreTypes.has(baseType) + } + + // Handle union types - check if any part is in typedefs + if (typeName.includes("|")) { + const types = typeName.split("|").map(t => t.trim()) + return types.some(t => coreTypes.has(t)) + } + + return coreTypes.has(typeName) +} + +// Check if a type is non-primitive (interface, type alias, or arrow function) +function isNonPrimitiveType(typeString) { + if (!typeString || PRIMITIVE_TYPES.has(typeString)) { + return false + } + + // Function types (arrow functions) + if (typeString.includes("=>")) { + return true + } + + // Object types with properties + if (typeString.includes("{") && typeString.includes(":")) { + return true + } + + // Complex union types + if (typeString.includes("|") && typeString.length > 20) { + return true + } + + // If it's not a primitive type and is a single word (likely an interface/type alias name) + // that starts with uppercase (TypeScript convention), it's probably a custom type + if (/^[A-Z][a-zA-Z0-9]*(\[\])?$/.test(typeString)) { + return true + } + + // Generic types like Promise where SomeType is not primitive + if (typeString.startsWith("Promise<") && typeString.endsWith(">")) { + const innerType = typeString.slice(8, -1).trim() + return isNonPrimitiveType(innerType) + } + + return false +} + +function extractTypeDefinition(sourceFile, node) { + if (!node) return null + + const text = sourceFile.getFullText().substring(node.getPos(), node.getEnd()) + + // Remove comments and extra whitespace + return text + .replace(/^[\r\n\s]+/, "") // Trim leading whitespace + .replace(/[\r\n\s]+$/, "") // Trim trailing whitespace + .replace(/\/\*\*[\s\S]*?\*\//g, "") // Remove JSDoc comments + .replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments + .replace(/\/\/.*$/gm, "") // Remove single-line comments + .replace(/\n\s*\n+/g, "\n") // Replace multiple blank lines with a single one + .trim() +} + +function findTypeInFile(sourceFile, typeName) { + // Check for interface + const iface = sourceFile.getInterface(typeName) + if (iface) return extractTypeDefinition(sourceFile, iface) + // Check for type alias + const typeAlias = sourceFile.getTypeAlias(typeName) + if (typeAlias) return extractTypeDefinition(sourceFile, typeAlias) + // Check for enum + const enumDef = sourceFile.getEnum(typeName) + if (enumDef) return extractTypeDefinition(sourceFile, enumDef) + + return null +} + +function getTypeDefinition(typeName, packageName, sourceFilePath) { + if (!typeName || PRIMITIVE_TYPES.has(typeName)) { + return null + } + + // Handle Promise and array types - extract base type + let baseTypeName = typeName + if (baseTypeName.startsWith("Promise<")) { + const match = baseTypeName.match(/Promise<(.+)>/) + if (match) baseTypeName = match[1] + } + if (baseTypeName.endsWith("[]")) { + baseTypeName = baseTypeName.slice(0, -2) + } + + // Check cache first + const cacheKey = `${packageName}:${baseTypeName}` + if (typeCache.has(cacheKey)) { + return typeCache.get(cacheKey) + } + + let definition = null + + try { + // First check source file if provided + if (sourceFilePath) { + const fullSourcePath = path.resolve(process.cwd(), "../", sourceFilePath) + if (fs.existsSync(fullSourcePath)) { + const project = new Project({skipAddingFilesFromTsConfig: true}) + const sourceFile = project.addSourceFileAtPath(fullSourcePath) + definition = findTypeInFile(sourceFile, baseTypeName) + } + } + + // If not found, search package src directory + if (!definition) { + const packageSrcDir = path.resolve( + process.cwd(), + "../", + packageName, + "src" + ) + if (fs.existsSync(packageSrcDir)) { + const project = new Project({skipAddingFilesFromTsConfig: true}) + project.addSourceFilesAtPaths(`${packageSrcDir}/**/*.ts`) + + for (const sourceFile of project.getSourceFiles()) { + definition = findTypeInFile(sourceFile, baseTypeName) + if (definition) break + } + } + } + + // Cache the result + typeCache.set(cacheKey, definition) + return definition + } catch (error) { + console.warn( + `Error getting type definition for ${typeName}: ${error.message}` + ) + return null + } +} + +function processTypeForDisplay( + typeString, + coreTypes, + packageName, + sourceFilePath +) { + if (!typeString) { + return { + displayType: typeString, + hasLink: false, + linkedType: null, + typeDefinition: null, + } + } + + const extractedType = extractTypeName(typeString) + + // Check if type exists in typedefs package + if (isTypeInTypedefs(extractedType, coreTypes)) { + let linkType = extractedType + let linkFragment = extractedType.toLowerCase() + + // Handle Promise - link to the inner type + if (extractedType.startsWith("Promise<") && extractedType.endsWith(">")) { + const innerType = extractedType.slice(8, -1).trim() + linkFragment = innerType.toLowerCase() + } + + // Handle Array types + if (extractedType.endsWith("[]")) { + const baseType = extractedType.slice(0, -2).trim() + linkFragment = baseType.toLowerCase() + } + + return { + displayType: extractedType, + hasLink: true, + linkedType: `[\`${extractedType}\`](../types#${linkFragment})`, + typeDefinition: null, + } + } + + // Check if it's a non-primitive type that should have a definition shown + if (isNonPrimitiveType(extractedType)) { + const typeDefinition = getTypeDefinition( + extractedType, + packageName, + sourceFilePath + ) + + return { + displayType: extractedType, + hasLink: false, + linkedType: null, + typeDefinition: typeDefinition || extractedType, // Show the type itself if no definition found + } + } + + // For primitive types or simple types, just show the type name + return { + displayType: extractedType, + hasLink: false, + linkedType: null, + typeDefinition: null, + } +} + +function processFunction(func, packageName, coreTypes) { + // Escape MDX characters in description + if (func.description) { + func.description = escapeMDXCharacters(func.description) + } + + // Process parameters + func.parameters = func.parameters.map(param => { + const typeInfo = processTypeForDisplay( + param.type, + coreTypes, + packageName, + func.sourceFilePath + ) + + // Escape MDX characters in parameter description + const description = param.description + ? escapeMDXCharacters(param.description) + : param.description + + return { + ...param, + type: typeInfo.displayType, + description, + linkedType: typeInfo.linkedType, + hasLink: typeInfo.hasLink, + typeDefinition: typeInfo.typeDefinition, + } + }) + + // Process return type + const returnTypeInfo = processTypeForDisplay( + func.returnType, + coreTypes, + packageName, + func.sourceFilePath + ) + + func.returnType = returnTypeInfo.displayType + func.returnHasLink = returnTypeInfo.hasLink + func.linkedType = returnTypeInfo.linkedType + func.returnTypeDefinition = returnTypeInfo.typeDefinition + + return func +} + +function truncateDescription(description, maxLength = 80) { + if (!description || description.length <= maxLength) { + // Still normalize whitespace even for short descriptions + return description ? description.replace(/\s+/g, " ").trim() : description + } + + // Remove line breaks and normalize whitespace + const normalizedDescription = description.replace(/\s+/g, " ").trim() + + // Find the last space before the maxLength to avoid cutting words + let truncateAt = maxLength + const lastSpace = normalizedDescription.lastIndexOf(" ", maxLength) + if (lastSpace > maxLength * 0.8) { + // Only use space if it's not too far back + truncateAt = lastSpace + } + + return normalizedDescription.substring(0, truncateAt).trim() + "..." +} + +function generateNamespacePage(templates, outputDir, packageName, namespace) { + const coreTypes = extractCoreTypes() + + // Deduplicate functions with the same name + const uniqueFunctions = [] + const seenFunctionNames = new Set() + namespace.functions.forEach(func => { + if (!seenFunctionNames.has(func.name)) { + seenFunctionNames.add(func.name) + uniqueFunctions.push(func) + } + }) + + // Sort functions alphabetically, case insensitively + uniqueFunctions.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ) + + // Process each function to add type definitions and links + const processedFunctions = uniqueFunctions.map(func => { + const processedFunc = processFunction(func, packageName, coreTypes) + // Add lowercase_name property for use in templates + processedFunc.lowercase_name = + func.name.charAt(0).toLowerCase() + func.name.slice(1) + return processedFunc + }) + + // Generate lowercase filename like functions + const filename = + namespace.name.charAt(0).toLowerCase() + namespace.name.slice(1) + + generatePage(templates, "namespace", path.join(outputDir, `${filename}.md`), { + packageName, + packageFirstWord: getFirstWord(packageName), + namespaceName: namespace.name, + namespaceDescription: namespace.description, + functions: processedFunctions, + }) +} + +module.exports = {generateNamespacePage} diff --git a/docs-generator/generators/generate-package-page.js b/docs-generator/generators/generate-package-page.js new file mode 100644 index 000000000..52c2a6618 --- /dev/null +++ b/docs-generator/generators/generate-package-page.js @@ -0,0 +1,112 @@ +const path = require("path") +const {generatePage, parseConfigCustomData, getFirstWord} = require("./utils") +const fs = require("fs") + +function truncateDescription(description, maxLength = 80) { + if (!description || description.length <= maxLength) { + // Still normalize whitespace even for short descriptions + return description ? description.replace(/\s+/g, " ").trim() : description + } + + // Remove line breaks and normalize whitespace + const normalizedDescription = description.replace(/\s+/g, " ").trim() + + // Find the last space before the maxLength to avoid cutting words + let truncateAt = maxLength + const lastSpace = normalizedDescription.lastIndexOf(" ", maxLength) + if (lastSpace > maxLength * 0.8) { + // Only use space if it's not too far back + truncateAt = lastSpace + } + + return normalizedDescription.substring(0, truncateAt).trim() + "..." +} + +function getPackageDescription(packageName) { + try { + const packageJsonPath = path.resolve(process.cwd(), "package.json") + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) + return packageJson.description || "" + } + } catch (error) { + console.warn( + `Error reading package.json for ${packageName}: ${error.message}` + ) + } + return "" +} + +function generatePackagePage( + templates, + outputDir, + packageName, + functions, + namespaces = [], + allNamespaceFunctions = [] +) { + const configPath = path.resolve(process.cwd(), "docs-generator.config.js") + const {displayName, sections, extra} = parseConfigCustomData(configPath) + const packageDescription = getPackageDescription(packageName) + + // Combine regular functions with namespace functions for the API reference + const allFunctions = [...functions, ...allNamespaceFunctions] + + // Deduplicate functions with the same name + const uniqueFunctions = [] + const seenFunctionNames = new Set() + allFunctions.forEach(func => { + if (!seenFunctionNames.has(func.name)) { + seenFunctionNames.add(func.name) + uniqueFunctions.push(func) + } + }) + + // Process namespaces for display + const processedNamespaces = namespaces.map(namespace => ({ + ...namespace, + displayDescription: truncateDescription(namespace.description), // Only truncate for display + type: "namespace", // Mark as namespace for sorting + isNamespace: true, // Add boolean flag for template + displayName: namespace.name, + filePath: `./${namespace.name.charAt(0).toLowerCase() + namespace.name.slice(1)}.md`, + })) + + // Process functions for display + const processedFunctions = uniqueFunctions.map(func => ({ + ...func, + lowercase_name: func.name.charAt(0).toLowerCase() + func.name.slice(1), + displayDescription: truncateDescription(func.description), // Only truncate for display + type: "function", // Mark as function for sorting + isNamespace: false, // Add boolean flag for template + displayName: func.namespace ? `${func.namespace}.${func.name}` : func.name, + filePath: func.namespace + ? `./${func.namespace.charAt(0).toLowerCase() + func.namespace.slice(1)}.md#${func.name}` + : `./${func.name.charAt(0).toLowerCase() + func.name.slice(1)}.md`, + })) + + // Combine functions and namespaces for unified sorting + const allApiItems = [...processedFunctions, ...processedNamespaces] + + // Sort all items alphabetically, case insensitively + allApiItems.sort((a, b) => + a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()) + ) + + generatePage(templates, "package", path.join(outputDir, "index.md"), { + packageName, + packageFirstWord: getFirstWord(packageName), + displayName: displayName || `@onflow/${packageName}`, + displayDescription: + packageDescription || `${packageName} package documentation.`, + customOverview: sections.overview, + customRequirements: sections.requirements, + customImporting: sections.importing, + extra, + functions: uniqueFunctions, // Keep original functions with full descriptions for individual pages + namespaces: processedNamespaces, + allApiItems, // Use processed items with truncated descriptions for the index + }) +} + +module.exports = {generatePackagePage} diff --git a/docs-generator/generators/generate-root-page.js b/docs-generator/generators/generate-root-page.js new file mode 100644 index 000000000..4dadaf578 --- /dev/null +++ b/docs-generator/generators/generate-root-page.js @@ -0,0 +1,75 @@ +const path = require("path") +const fs = require("fs") +const {generatePage, parseConfigCustomData} = require("./utils") + +function getPackageDescription(packageName) { + try { + const packageJsonPath = path.resolve( + process.cwd(), + "..", + packageName, + "package.json" + ) + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) + return packageJson.description || "" + } + } catch (error) { + console.warn( + `Error reading package.json for ${packageName}: ${error.message}` + ) + } + return "" +} + +function generateRootPage(templates, packagesDir, currentPackageName) { + const configPath = path.resolve(process.cwd(), "docs-generator.config.js") + const {displayName} = parseConfigCustomData(configPath) + + const currentDisplayName = displayName || `@onflow/${currentPackageName}` + const currentPackageDescription = + getPackageDescription(currentPackageName) || + `${currentPackageName} package documentation.` + const rootPagePath = path.join(packagesDir, "index.md") + const packages = [] + + // Read existing packages from the file if it exists + if (fs.existsSync(rootPagePath)) { + try { + const content = fs.readFileSync(rootPagePath, "utf8") + const regex = /\- \[(.*?)\]\(\.\/(.*?)\/index\.md\)/g + let match + while ((match = regex.exec(content)) !== null) { + if (match[1] && match[2] && match[1] !== "Type Definitions") { + packages.push({ + displayName: match[1], + packageName: match[2], + displayDescription: getPackageDescription(match[2]), + }) + } + } + } catch (error) { + console.warn(`Error reading root page: ${error.message}`) + } + } + + // Check if current package already exists + const packageExists = packages.some( + pkg => pkg.packageName === currentPackageName + ) + // Add it if not already in the list + if (!packageExists) { + packages.push({ + displayName: currentDisplayName, + packageName: currentPackageName, + displayDescription: currentPackageDescription, + }) + } + // Sort packages by display name + packages.sort((a, b) => a.displayName.localeCompare(b.displayName)) + + // Generate the root page + generatePage(templates, "root", rootPagePath, {packages}) +} + +module.exports = {generateRootPage} diff --git a/docs-generator/generators/generate-types-page.js b/docs-generator/generators/generate-types-page.js new file mode 100644 index 000000000..2f7e27747 --- /dev/null +++ b/docs-generator/generators/generate-types-page.js @@ -0,0 +1,356 @@ +const path = require("path") +const {Project, Node, TypeFormatFlags} = require("ts-morph") +const {generatePage} = require("./utils") + +function decodeHtmlEntities(text) { + if (!text) return text + + const decoded = text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + + // Escape also pipe characters which would break markdown tables + return decoded.replace(/\|/g, "\\|") +} + +function extractJSDocDescription(node) { + if (!node) return null + + try { + if (typeof node.getJsDocs === "function") { + const jsDocs = node.getJsDocs() + if (jsDocs && jsDocs.length > 0) { + const jsDoc = jsDocs[0] + const description = jsDoc.getDescription() || "" + return description.trim() || null + } + } + + // Fallback: try to parse JSDoc from the node leading comments + if (typeof node.getLeadingCommentRanges === "function") { + const commentRanges = node.getLeadingCommentRanges() + if (commentRanges && commentRanges.length > 0) { + const commentText = commentRanges + .map(range => range.getText()) + .join("\n") + // Simple regex to extract JSDoc description + const match = /\/\*\*\s*([\s\S]*?)\s*\*\//.exec(commentText) + if (match && match[1]) { + const description = match[1].replace(/^\s*\*\s?/gm, "").trim() + return description || null + } + } + } + } catch (error) { + console.warn(`Error extracting JSDoc description: ${error.message}`) + } + return null +} + +function extractConstBasedTypes(sourceFiles) { + const constBasedTypes = [] + + sourceFiles.forEach(sourceFile => { + // Get exported type aliases + sourceFile.getTypeAliases().forEach(typeAlias => { + if (!typeAlias.isExported()) return + + const name = typeAlias.getName() + const typeText = typeAlias.getTypeNode()?.getText() || "" + + // Check if this is a template literal type based on a const object + // Pattern: (typeof SomeName)[keyof typeof SomeName] + const templateLiteralPattern = /\(typeof\s+(\w+)\)\[keyof\s+typeof\s+\1\]/ + const match = typeText.match(templateLiteralPattern) + + if (match) { + const constName = match[1] + + // Find the corresponding const object in the same file + const constDeclaration = sourceFile.getVariableStatement(stmt => { + const declarations = stmt.getDeclarations() + return declarations.some(decl => { + if (decl.getName() !== constName || !decl.hasExportKeyword()) { + return false + } + + const initializer = decl.getInitializer() + if (!initializer) return false + + // Check for direct object literal + if (Node.isObjectLiteralExpression(initializer)) { + return true + } + + // Check for object literal with 'as const' assertion + if (Node.isAsExpression(initializer)) { + return Node.isObjectLiteralExpression(initializer.getExpression()) + } + + return false + }) + }) + + if (constDeclaration) { + const constVarDecl = constDeclaration + .getDeclarations() + .find(decl => decl.getName() === constName) + const description = + extractJSDocDescription(typeAlias) || + extractJSDocDescription(constVarDecl) + + // Extract properties from the const object + const members = [] + const initializer = constVarDecl.getInitializer() + + let objectLiteral = null + if (Node.isObjectLiteralExpression(initializer)) { + objectLiteral = initializer + } else if (Node.isAsExpression(initializer)) { + const expression = initializer.getExpression() + if (Node.isObjectLiteralExpression(expression)) { + objectLiteral = expression + } + } + + if (objectLiteral) { + objectLiteral.getProperties().forEach(prop => { + if (Node.isPropertyAssignment(prop)) { + const memberName = prop.getName() + const memberValue = prop.getInitializer()?.getText() + + // Try to extract JSDoc from leading comments + let memberDescription = null + const leadingComments = prop.getLeadingCommentRanges() + if (leadingComments && leadingComments.length > 0) { + const commentText = leadingComments + .map(range => range.getText()) + .join("\n") + // Extract single-line comments + const singleLineMatch = /\/\/\s*(.+)/.exec(commentText) + if (singleLineMatch) { + memberDescription = singleLineMatch[1].trim() + } + } + + members.push({ + name: memberName, + value: memberValue, + description: memberDescription, + }) + } + }) + } + + constBasedTypes.push({ + name, + description, + members, + importStatement: `import { ${name} } from "@onflow/fcl"`, + }) + } + } + }) + }) + + return constBasedTypes.sort((a, b) => a.name.localeCompare(b.name)) +} + +function extractInterfaces(sourceFiles) { + const interfaces = [] + + sourceFiles.forEach(sourceFile => { + // Get exported interfaces + sourceFile.getInterfaces().forEach(iface => { + if (!iface.isExported()) return + + const name = iface.getName() + const description = extractJSDocDescription(iface) + + // Extract properties + const properties = iface.getProperties().map(prop => { + const propName = prop.getName() + const propType = decodeHtmlEntities( + prop.getType().getText(undefined, TypeFormatFlags.None) + ) + const propDescription = extractJSDocDescription(prop) + + return { + name: propName, + type: propType, + description: propDescription, + } + }) + + interfaces.push({ + name, + description, + properties, + importStatement: `import { type ${name} } from "@onflow/fcl"`, + }) + }) + }) + // Sort interfaces alphabetically by name + return interfaces.sort((a, b) => a.name.localeCompare(b.name)) +} + +function extractTypeAliases(sourceFiles) { + const types = [] + + sourceFiles.forEach(sourceFile => { + // Get exported type aliases + sourceFile.getTypeAliases().forEach(typeAlias => { + if (!typeAlias.isExported()) return + + const name = typeAlias.getName() + const typeText = typeAlias.getTypeNode()?.getText() || "" + + // Skip template literal types based on const objects - they'll be handled by extractConstBasedTypes + const templateLiteralPattern = /\(typeof\s+(\w+)\)\[keyof\s+typeof\s+\1\]/ + if (templateLiteralPattern.test(typeText)) { + return + } + + const description = extractJSDocDescription(typeAlias) + const type = decodeHtmlEntities( + typeAlias.getType().getText(undefined, TypeFormatFlags.None) + ) + + // For object types, try to extract properties + const properties = [] + const aliasType = typeAlias.getType() + + if (aliasType.isObject()) { + const propSymbols = aliasType.getProperties() + + propSymbols.forEach(propSymbol => { + const propName = propSymbol.getName() + let propType = "unknown" + + try { + const valueDeclaration = propSymbol.getValueDeclaration() + if (valueDeclaration) { + if (Node.isPropertySignature(valueDeclaration)) { + const typeNode = valueDeclaration.getTypeNode() + if (typeNode) { + propType = typeNode.getText() + } + } + } + + // If we couldn't get the type from the declaration, use the symbol's type + if (propType === "unknown") { + propType = propSymbol + .getTypeAtLocation(typeAlias) + .getText(undefined, TypeFormatFlags.None) + } + + properties.push({ + name: propName, + type: decodeHtmlEntities(propType), + description: + extractJSDocDescription(propSymbol.getDeclarations()[0]) || + null, + }) + } catch (error) { + console.warn( + `Error extracting property ${propName} from type ${name}: ${error.message}` + ) + properties.push({ + name: propName, + type: "unknown", + description: null, + }) + } + }) + } + + types.push({ + name, + description, + type, + properties, + importStatement: `import { type ${name} } from "@onflow/fcl"`, + }) + }) + }) + // Sort type aliases alphabetically by name + return types.sort((a, b) => a.name.localeCompare(b.name)) +} + +function extractEnums(sourceFiles) { + const enums = [] + + sourceFiles.forEach(sourceFile => { + // Get exported enums + sourceFile.getEnums().forEach(enumDef => { + if (!enumDef.isExported()) return + + const name = enumDef.getName() + const description = extractJSDocDescription(enumDef) + + // Extract members + const members = enumDef.getMembers().map(member => { + const memberName = member.getName() + const memberDescription = extractJSDocDescription(member) + let memberValue = null + + // Try to get the value + const valueNode = member.getInitializer() + if (valueNode) { + memberValue = valueNode.getText() + } + + return { + name: memberName, + value: memberValue, + description: memberDescription, + } + }) + + enums.push({ + name, + description, + members, + importStatement: `import { ${name} } from "@onflow/fcl"`, + }) + }) + }) + // Sort enums alphabetically by name + return enums.sort((a, b) => a.name.localeCompare(b.name)) +} + +function generateTypesPage(templates, outputDir) { + // Path to the typedefs package + const typedefsDir = path.resolve(process.cwd(), "../typedefs") + const typedefsSrcDir = path.join(typedefsDir, "src") + + // Initialize ts-morph project + const project = new Project({ + skipAddingFilesFromTsConfig: true, + }) + // Add source files from typedefs package + project.addSourceFilesAtPaths(`${typedefsSrcDir}/**/*.ts`) + const sourceFiles = project.getSourceFiles() + + // Extract types data + const interfaces = extractInterfaces(sourceFiles) + const types = extractTypeAliases(sourceFiles) + const enums = extractEnums(sourceFiles) + const constBasedTypes = extractConstBasedTypes(sourceFiles) + + // Combine regular types with const-based types + const allTypes = [...types, ...constBasedTypes] + + // Generate the types index page + generatePage(templates, "types", path.join(outputDir, "index.md"), { + interfaces, + types: allTypes, + enums, + }) +} + +module.exports = {generateTypesPage} diff --git a/docs-generator/generators/index.js b/docs-generator/generators/index.js new file mode 100644 index 000000000..f5f5e234c --- /dev/null +++ b/docs-generator/generators/index.js @@ -0,0 +1,13 @@ +const {generatePackagePage} = require("./generate-package-page") +const {generateFunctionPage} = require("./generate-function-page") +const {generateTypesPage} = require("./generate-types-page") +const {generateRootPage} = require("./generate-root-page") +const {generateNamespacePage} = require("./generate-namespace-page") + +module.exports = { + generatePackagePage, + generateFunctionPage, + generateTypesPage, + generateRootPage, + generateNamespacePage, +} diff --git a/docs-generator/generators/utils/extract-utils.js b/docs-generator/generators/utils/extract-utils.js new file mode 100644 index 000000000..1cbf93abc --- /dev/null +++ b/docs-generator/generators/utils/extract-utils.js @@ -0,0 +1,5 @@ +function getFirstWord(packageName) { + return packageName.split("-")[0] +} + +module.exports = {getFirstWord} diff --git a/docs-generator/generators/utils/generate-page.js b/docs-generator/generators/utils/generate-page.js new file mode 100644 index 000000000..48c15ef24 --- /dev/null +++ b/docs-generator/generators/utils/generate-page.js @@ -0,0 +1,13 @@ +const fs = require("fs") + +function generatePage(templates, templateName, outputPath, context) { + try { + const content = templates[templateName](context) + fs.writeFileSync(outputPath, content) + } catch (error) { + console.error(`Error generating ${templateName} page: ${error.message}`) + throw error + } +} + +module.exports = {generatePage} diff --git a/docs-generator/generators/utils/index.js b/docs-generator/generators/utils/index.js new file mode 100644 index 000000000..035ea4a71 --- /dev/null +++ b/docs-generator/generators/utils/index.js @@ -0,0 +1,5 @@ +const {generatePage} = require("./generate-page") +const {parseConfigCustomData} = require("./parse-config-custom-data") +const {getFirstWord} = require("./extract-utils") + +module.exports = {generatePage, parseConfigCustomData, getFirstWord} diff --git a/docs-generator/generators/utils/parse-config-custom-data.js b/docs-generator/generators/utils/parse-config-custom-data.js new file mode 100644 index 000000000..38babb0f3 --- /dev/null +++ b/docs-generator/generators/utils/parse-config-custom-data.js @@ -0,0 +1,17 @@ +const fs = require("fs") + +function parseConfigCustomData(configPath) { + // Parse config custom data if present and return custom one or null for default + const config = fs.existsSync(configPath) ? require(configPath) : null + return { + displayName: config?.customData?.displayName || null, + sections: { + overview: config?.customData?.sections?.overview || null, + requirements: config?.customData?.sections?.requirements || null, + importing: config?.customData?.sections?.importing || null, + }, + extra: config?.customData?.extra || null, + } +} + +module.exports = {parseConfigCustomData} diff --git a/docs-generator/templates/function.hbs b/docs-generator/templates/function.hbs new file mode 100644 index 000000000..78781c9a2 --- /dev/null +++ b/docs-generator/templates/function.hbs @@ -0,0 +1,79 @@ +--- +sidebar_position: 1 +title: "{{name}}" +description: "{{name}} function documentation." +--- + + + +# {{name}} + +{{#if description}} +{{{description}}} +{{/if}} + +## Import + +You can import the entire package and access the function: + +```typescript +import * as {{packageFirstWord}} from "@onflow/{{packageName}}" + +{{packageFirstWord}}.{{name}}({{#each parameters}}{{#unless @first}}, {{/unless}}{{name}}{{/each}}) +``` + +Or import directly the specific function: + +```typescript +import { {{name}} } from "@onflow/{{packageName}}" + +{{name}}({{#each parameters}}{{#unless @first}}, {{/unless}}{{name}}{{/each}}) +``` + +{{#if customExample}} +## Usage + +```typescript +{{{customExample}}} +``` +{{/if}} + +{{#if parameters.length}} +## Parameters + +{{#each parameters}} +### `{{name}}` {{#unless required}}(optional){{/unless}} + +{{#if typeDefinition}}{{#unless hasLink}} +- Type: +```typescript +{{{typeDefinition}}} +``` +{{else}} +- Type: {{#if hasLink}}{{{linkedType}}}{{else}}`{{{type}}}`{{/if}} +{{/unless}}{{else}} +- Type: {{#if hasLink}}{{{linkedType}}}{{else}}`{{{type}}}`{{/if}} +{{/if}} +{{#if description}} +- Description: {{{description}}} +{{/if}} + +{{/each}} +{{/if}} + +## Returns + +{{#if returnTypeDefinition}} +```typescript +{{{returnTypeDefinition}}} +``` +{{else}} +{{#if returnHasLink}}{{{linkedType}}}{{else}}`{{{returnType}}}`{{/if}} +{{/if}} + +{{#if returnDescription}} + +{{{returnDescription}}} +{{/if}} + +--- \ No newline at end of file diff --git a/docs-generator/templates/namespace.hbs b/docs-generator/templates/namespace.hbs new file mode 100644 index 000000000..c7b625c2d --- /dev/null +++ b/docs-generator/templates/namespace.hbs @@ -0,0 +1,84 @@ +--- +sidebar_position: 1 +title: "{{namespaceName}}" +description: "{{namespaceDescription}}" +--- + + + +# {{namespaceName}} + +## Overview + +{{namespaceDescription}} + +## Functions + +{{#each functions}} +### {{name}} + +{{#if description}} +{{{description}}} +{{/if}} + +#### Import + +You can import the entire package and access the function: + +```typescript +import * as {{../packageFirstWord}} from "@onflow/{{../packageName}}" + +{{../packageFirstWord}}.{{../namespaceName}}.{{name}}({{#each parameters}}{{#unless @first}}, {{/unless}}{{name}}{{/each}}) +``` + +Or import the namespace directly: + +```typescript +import { {{../namespaceName}} } from "@onflow/{{../packageName}}" + +{{../namespaceName}}.{{name}}({{#each parameters}}{{#unless @first}}, {{/unless}}{{name}}{{/each}}) +``` + +{{#if customExample}} +#### Usage + +```typescript +{{{customExample}}} +``` +{{/if}} + +{{#if parameters.length}} +#### Parameters + +{{#each parameters}} +##### `{{name}}`{{#unless required}} (optional){{/unless}} + +{{#if typeDefinition}}{{#unless hasLink}} +- Type: +```typescript +{{{typeDefinition}}} +``` +{{else}} +- Type: {{#if hasLink}}{{{linkedType}}}{{else}}`{{{type}}}`{{/if}} +{{/unless}}{{else}} +- Type: {{#if hasLink}}{{{linkedType}}}{{else}}`{{{type}}}`{{/if}} +{{/if}} +{{#if description}} +- Description: {{{description}}} +{{/if}} + +{{/each}} +{{/if}} +#### Returns + +{{#if returnTypeDefinition}} +```typescript +{{{returnTypeDefinition}}} +``` +{{else}} +{{#if returnHasLink}}{{{linkedType}}}{{else}}`{{{returnType}}}`{{/if}} +{{/if}} + +{{/each}} + +--- \ No newline at end of file diff --git a/docs-generator/templates/package.hbs b/docs-generator/templates/package.hbs new file mode 100644 index 000000000..b17a81104 --- /dev/null +++ b/docs-generator/templates/package.hbs @@ -0,0 +1,75 @@ +--- +sidebar_position: 1 +title: "{{displayName}}" +description: "{{#if displayDescription}}{{displayDescription}}{{else}}{{displayName}} package documentation.{{/if}}" +--- + + + +# {{displayName}} + +## Overview + +{{#if customOverview}} +{{{customOverview}}} +{{else}} +The Flow {{packageName}} library provides a set of tools for developers to build applications on the Flow blockchain. +{{/if}} + +## Installation + +You can install the @onflow/{{packageName}} package using npm or yarn: + +```bash +npm install @onflow/{{packageName}} +``` + +Or using yarn: + +```bash +yarn add @onflow/{{packageName}} +``` + +### Requirements + +{{#if customRequirements}} +{{{customRequirements}}} +{{else}} +- Node.js 14.x or later +{{/if}} + +### Importing + +{{#if customImporting}} +{{{customImporting}}} +{{else}} +You can import the entire package: + +```typescript +import * as {{packageFirstWord}} from "@onflow/{{packageName}}" +``` + +Or import specific functions: + +```typescript +import { functionName } from "@onflow/{{packageName}}" +``` +{{/if}} + +{{#if extra}} +{{{extra}}} +{{/if}} + +## API Reference + +This section contains documentation for all of the functions and namespaces in the {{packageName}} package. + +{{#each allApiItems}} +{{#if isNamespace}} +- [{{displayName}}]({{filePath}}) (namespace){{#if displayDescription}} - {{displayDescription}}{{/if}} +{{else}} +- [{{displayName}}]({{filePath}}){{#if displayDescription}} - {{displayDescription}}{{/if}} +{{/if}} +{{/each}} + +--- \ No newline at end of file diff --git a/docs-generator/templates/root.hbs b/docs-generator/templates/root.hbs new file mode 100644 index 000000000..041dc618f --- /dev/null +++ b/docs-generator/templates/root.hbs @@ -0,0 +1,19 @@ +--- +sidebar_position: 1 +title: Packages Docs +description: Packages documentation. +--- + + + +# Packages Docs + +A list of all packages available inside Flow Client Library (FCL) with functions and type definitions. + +{{#each packages}} +- [{{this.displayName}}](./{{this.packageName}}/index.md) - {{this.displayDescription}} +{{/each}} + +- [Type Definitions](./types/index.md) - Type definitions for the Flow Client Library (FCL) packages. + +--- diff --git a/docs-generator/templates/types.hbs b/docs-generator/templates/types.hbs new file mode 100644 index 000000000..6d997edfb --- /dev/null +++ b/docs-generator/templates/types.hbs @@ -0,0 +1,101 @@ +--- +title: Type Definitions +description: Type definitions for the Flow Client Library (FCL) packages. +--- + + + +# Type Definitions + +Documentation for core types used throughout the Flow Client Library (FCL). + +{{#if interfaces.length}} +## Interfaces + +{{#each interfaces}} +### {{name}} + +```typescript +{{{importStatement}}} +``` + +{{#if description}} +{{{description}}} +{{/if}} + +{{#if properties.length}} +**Properties:** + +| Name | Type | Description | +| ---- | ---- | ----------- | +{{#each properties}} +| `{{name}}` | `{{{type}}}` | {{#if description}}{{{description}}}{{/if}} | +{{/each}} +{{/if}} + +{{/each}} +{{/if}} + +{{#if types.length}} +## Types + +{{#each types}} +### {{name}} + +```typescript +{{{importStatement}}} +``` + +{{#if description}} +{{{description}}} +{{/if}} + +{{#if properties.length}} +**Properties:** + +| Name | Type | Description | +| ---- | ---- | ----------- | +{{#each properties}} +| `{{name}}` | `{{{type}}}` | {{#if description}}{{{description}}}{{/if}} | +{{/each}} +{{/if}} + +{{#if members.length}} +**Members:** + +| Name | Value | +| ---- | ----- | +{{#each members}} +| `{{name}}` | {{{value}}} | +{{/each}} +{{/if}} + +{{/each}} +{{/if}} + +{{#if enums.length}} +## Enums + +{{#each enums}} +### {{name}} + +```typescript +{{{importStatement}}} +``` + +{{#if description}} +{{{description}}} +{{/if}} + +**Members:** + +| Name | Value | +| ---- | ----- | +{{#each members}} +| `{{name}}` | {{#if value}}{{{value}}}{{else}}`"{{name}}"`{{/if}} | +{{/each}} + +{{/each}} +{{/if}} + +--- \ No newline at end of file diff --git a/docs-generator/utils/export-extractor.js b/docs-generator/utils/export-extractor.js new file mode 100644 index 000000000..dccff26a4 --- /dev/null +++ b/docs-generator/utils/export-extractor.js @@ -0,0 +1,228 @@ +const path = require("path") +const {Node} = require("ts-morph") +const {parseJsDoc} = require("./jsdoc-parser") +const { + extractFunctionInfo, + resolveReExportedFunction, +} = require("./function-extractor") +const {extractNamespaceFunctions} = require("./namespace-utils") + +function extractExportsFromEntryFile(sourceFile) { + const functions = [] + const namespaces = [] + const processedReExports = new Set() // Track already processed re-exports + const filePath = sourceFile.getFilePath() + const relativeFilePath = path.relative(process.cwd(), filePath) + + try { + // Get all import declarations to track namespaces + const importDeclarations = sourceFile.getImportDeclarations() + const actualNamespaceImports = new Map() // Only for "import * as X" style imports + const namedImports = new Map() // For regular named imports like "import {build}" + const typeOnlyImports = new Set() // Track type-only imports + + importDeclarations.forEach(importDecl => { + // Skip entire type-only import declarations + if (importDecl.isTypeOnly && importDecl.isTypeOnly()) { + return + } + + // Handle regular named imports + const namedImportsList = importDecl.getNamedImports() + namedImportsList.forEach(namedImport => { + const name = namedImport.getName() + + // Check if this is a type-only import (either the import declaration or the named import) + if ( + namedImport.isTypeOnly() || + (importDecl.isTypeOnly && importDecl.isTypeOnly()) + ) { + typeOnlyImports.add(name) + return // Skip adding to namedImports + } + + namedImports.set(name, importDecl) + }) + + // Handle actual namespace imports like "import * as types" + const namespaceImport = importDecl.getNamespaceImport() + if (namespaceImport) { + const name = namespaceImport.getText() + // Only add if it's not a type-only import + if (!(importDecl.isTypeOnly && importDecl.isTypeOnly())) { + actualNamespaceImports.set(name, importDecl) + } + } + }) + + // Get all export declarations to find re-exports and namespace exports with aliases + sourceFile.getExportDeclarations().forEach(exportDecl => { + // Skip type-only exports + if (exportDecl.isTypeOnly()) { + return + } + + const namedExports = exportDecl.getNamedExports() + namedExports.forEach(namedExport => { + // Skip type-only named exports + if (namedExport.isTypeOnly()) { + return + } + + const exportName = namedExport.getName() + const alias = namedExport.getAliasNode()?.getText() + const finalName = alias || exportName + + // Skip if this is a type-only import + if (typeOnlyImports.has(exportName)) { + return + } + + // Check if this is a re-export from another module + const moduleSpecifier = exportDecl.getModuleSpecifier() + if (moduleSpecifier) { + const moduleSpecifierValue = moduleSpecifier.getLiteralValue() + const reExportedFuncInfo = resolveReExportedFunction( + sourceFile, + exportName, + moduleSpecifierValue + ) + if (reExportedFuncInfo) { + reExportedFuncInfo.name = finalName + functions.push(reExportedFuncInfo) + // Track that this function was processed as a re-export + processedReExports.add(finalName) + } + } else { + // This is an export of something imported - check if it's an actual namespace + if ( + actualNamespaceImports.has(exportName) && + !typeOnlyImports.has(exportName) + ) { + const importDecl = actualNamespaceImports.get(exportName) + const namespaceFunctions = extractNamespaceFunctions( + sourceFile, + exportName, + importDecl + ) + // Only add as namespace if it actually has functions + if (namespaceFunctions.length > 0) { + namespaces.push({ + name: finalName, + functions: namespaceFunctions, + description: `Namespace containing ${finalName} utilities`, + }) + } + } + // Also check if it's a named import that might be a namespace object + else if ( + namedImports.has(exportName) && + !typeOnlyImports.has(exportName) + ) { + const importDecl = namedImports.get(exportName) + const namespaceFunctions = extractNamespaceFunctions( + sourceFile, + exportName, + importDecl + ) + // Only add as namespace if it actually has functions + if (namespaceFunctions.length > 0) { + namespaces.push({ + name: finalName, + functions: namespaceFunctions, + description: `Namespace containing ${finalName} utilities`, + }) + } + } + } + }) + }) + + // Get exported declarations from the current file + sourceFile.getExportedDeclarations().forEach((declarations, name) => { + // Skip if this function was already processed as a re-export + if (processedReExports.has(name)) { + return + } + + declarations.forEach(declaration => { + // Skip type declarations, interfaces, and type aliases + if ( + Node.isTypeAliasDeclaration(declaration) || + Node.isInterfaceDeclaration(declaration) || + Node.isEnumDeclaration(declaration) + ) { + // Skip these as they are types, not functions + return + } + + const funcInfo = extractFunctionInfo(declaration, name, sourceFile) + + if (funcInfo) { + functions.push(funcInfo) + } + // Check if this is a namespace export for variable declarations + else if (Node.isVariableDeclaration(declaration)) { + // Only check if this is a namespace export if it's an actual namespace import + // and it's not a type-only import + if ( + actualNamespaceImports.has(name) && + !typeOnlyImports.has(name) && + !namespaces.some(ns => ns.name === name) + ) { + const importDecl = actualNamespaceImports.get(name) + const namespaceFunctions = extractNamespaceFunctions( + sourceFile, + name, + importDecl + ) + // Only add as namespace if it actually has functions + if (namespaceFunctions.length > 0) { + const jsDocInfo = parseJsDoc(declaration) + namespaces.push({ + name, + functions: namespaceFunctions, + description: + jsDocInfo.description || + `Namespace containing ${name} utilities`, + }) + } + } + // Also check if it's a named import that might be a namespace object + else if ( + namedImports.has(name) && + !typeOnlyImports.has(name) && + !namespaces.some(ns => ns.name === name) + ) { + const importDecl = namedImports.get(name) + const namespaceFunctions = extractNamespaceFunctions( + sourceFile, + name, + importDecl + ) + // Only add as namespace if it actually has functions + if (namespaceFunctions.length > 0) { + const jsDocInfo = parseJsDoc(declaration) + namespaces.push({ + name, + functions: namespaceFunctions, + description: + jsDocInfo.description || + `Namespace containing ${name} utilities`, + }) + } + } + } + }) + }) + } catch (e) { + console.warn(`Error extracting exports from entry file: ${e.message}`) + console.warn(e.stack) + } + + return {functions, namespaces} +} + +module.exports = { + extractExportsFromEntryFile, +} diff --git a/docs-generator/utils/file-utils.js b/docs-generator/utils/file-utils.js new file mode 100644 index 000000000..54ade3013 --- /dev/null +++ b/docs-generator/utils/file-utils.js @@ -0,0 +1,37 @@ +const fs = require("fs") +const path = require("path") + +function discoverWorkspacePackages() { + try { + // Get the workspace root (2 levels up from current package directory) + const workspaceRoot = path.resolve(process.cwd(), "../..") + const packagesDir = path.join(workspaceRoot, "packages") + + if (!fs.existsSync(packagesDir)) { + console.warn("Packages directory not found, using current package only") + return [] + } + + const packagePaths = [] + const packageDirs = fs + .readdirSync(packagesDir, {withFileTypes: true}) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + + for (const packageDir of packageDirs) { + const srcPath = path.join(packagesDir, packageDir, "src") + if (fs.existsSync(srcPath)) { + packagePaths.push(`${srcPath}/**/*.ts`) + } + } + + return packagePaths + } catch (e) { + console.warn(`Error discovering workspace packages: ${e.message}`) + return [] + } +} + +module.exports = { + discoverWorkspacePackages, +} diff --git a/docs-generator/utils/function-extractor.js b/docs-generator/utils/function-extractor.js new file mode 100644 index 000000000..e02975a8d --- /dev/null +++ b/docs-generator/utils/function-extractor.js @@ -0,0 +1,374 @@ +const path = require("path") +const fs = require("fs") +const {Node} = require("ts-morph") +const {parseJsDoc} = require("./jsdoc-parser") +const { + cleanupTypeText, + toCamelCase, + escapeParameterNameForMDX, + escapeTextForMDX, +} = require("./type-utils") + +function extractFunctionInfo( + declaration, + functionName, + sourceFile, + namespace = null +) { + try { + let funcInfo = null + + // Handle function declarations + if (Node.isFunctionDeclaration(declaration)) { + const jsDocInfo = parseJsDoc(declaration) + const parameters = declaration.getParameters().map(param => { + let paramName = param.getName() + const paramType = cleanupTypeText(param.getType().getText()) + const paramJsDoc = jsDocInfo.params && jsDocInfo.params[paramName] + + // Handle destructured parameters by using camelCase of the type name + if (paramName.includes("{") && paramName.includes("}")) { + // Extract the type name from the parameter type + // Handle both direct types (AccountProofData) and import types (import("...").AccountProofData) + const typeText = param.getType().getText() + let typeMatch = typeText.match(/^([A-Z][a-zA-Z0-9]*)/) // Direct type + if (!typeMatch) { + typeMatch = typeText.match(/import\([^)]+\)\.([A-Z][a-zA-Z0-9]*)/) // Import type + } + + if (typeMatch && typeMatch[1]) { + paramName = toCamelCase(typeMatch[1]) + } else { + // Fallback to "options" if we can't extract a type name + paramName = "options" + } + } + + return { + name: escapeParameterNameForMDX(paramName), + type: paramType, + required: !param.isOptional(), + description: escapeTextForMDX(paramJsDoc) || "", + } + }) + + const returnType = cleanupTypeText(declaration.getReturnType().getText()) + + // Extract return description from JSDoc + let returnDescription = null + if (jsDocInfo.returns) { + returnDescription = escapeTextForMDX(jsDocInfo.returns) + } + + const filePath = sourceFile.getFilePath() + const relativeFilePath = path.relative(process.cwd(), filePath) + + funcInfo = { + name: functionName, + returnType, + returnDescription, + parameters, + description: jsDocInfo.description || "", + customExample: jsDocInfo.example || "", + sourceFilePath: relativeFilePath, + } + } + // Handle variable declarations with function values + else if (Node.isVariableDeclaration(declaration)) { + let jsDocInfo = parseJsDoc(declaration) + + // If no JSDoc found on the declaration, try the parent VariableStatement + if (!jsDocInfo.description) { + const parentList = declaration.getParent() + if (parentList) { + const parentStatement = parentList.getParent() + if (parentStatement) { + jsDocInfo = parseJsDoc(parentStatement) + } + } + } + + const initializer = declaration.getInitializer() + + if ( + initializer && + (Node.isFunctionExpression(initializer) || + Node.isArrowFunction(initializer)) + ) { + const parameters = initializer.getParameters().map(param => { + let paramName = param.getName() + const paramType = cleanupTypeText(param.getType().getText()) + const paramJsDoc = jsDocInfo.params && jsDocInfo.params[paramName] + + // Handle destructured parameters by using camelCase of the type name + if (paramName.includes("{") && paramName.includes("}")) { + // Extract the type name from the parameter type + // Handle both direct types (AccountProofData) and import types (import("...").AccountProofData) + const typeText = param.getType().getText() + let typeMatch = typeText.match(/^([A-Z][a-zA-Z0-9]*)/) // Direct type + if (!typeMatch) { + typeMatch = typeText.match(/import\([^)]+\)\.([A-Z][a-zA-Z0-9]*)/) // Import type + } + + if (typeMatch && typeMatch[1]) { + paramName = toCamelCase(typeMatch[1]) + } else { + // Fallback to "options" if we can't extract a type name + paramName = "options" + } + } + + return { + name: escapeParameterNameForMDX(paramName), + type: paramType, + required: !param.isOptional(), + description: escapeTextForMDX(paramJsDoc) || "", + } + }) + + const returnType = cleanupTypeText( + initializer.getReturnType().getText() + ) + + // Extract return description from JSDoc + let returnDescription = null + if (jsDocInfo.returns) { + returnDescription = escapeTextForMDX(jsDocInfo.returns) + } + + const filePath = sourceFile.getFilePath() + const relativeFilePath = path.relative(process.cwd(), filePath) + + funcInfo = { + name: functionName, + returnType, + returnDescription, + parameters, + description: jsDocInfo.description || "", + customExample: jsDocInfo.example || "", + sourceFilePath: relativeFilePath, + } + } + // Handle variable declarations with JSDoc that represent functions + // (like resolve = pipe([...]) or other function-returning expressions) + else if (jsDocInfo.description || jsDocInfo.params || jsDocInfo.returns) { + // Extract parameter information from JSDoc if available + const parameters = [] + if (jsDocInfo.params) { + Object.entries(jsDocInfo.params).forEach(([paramName, paramDesc]) => { + parameters.push({ + name: escapeParameterNameForMDX(paramName), + type: "any", // Default type since we can't infer from call expressions + required: true, // Default to required + description: escapeTextForMDX(paramDesc) || "", + }) + }) + } + + // Get return type from JSDoc or try to infer from the variable type + let returnType = "any" + let returnDescription = null + if (jsDocInfo.returns) { + returnDescription = escapeTextForMDX(jsDocInfo.returns) + } + + // Always try to get the actual TypeScript return type + try { + returnType = cleanupTypeText(declaration.getType().getText()) + } catch (e) { + // Fallback to any if type inference fails + returnType = "any" + } + + const filePath = sourceFile.getFilePath() + const relativeFilePath = path.relative(process.cwd(), filePath) + + funcInfo = { + name: functionName, + returnType, + returnDescription, + parameters, + description: jsDocInfo.description || "", + customExample: jsDocInfo.example || "", + sourceFilePath: relativeFilePath, + } + } + } + + // Add namespace if provided + if (funcInfo && namespace) { + funcInfo.namespace = namespace + } + + return funcInfo + } catch (e) { + console.warn( + `Error extracting function info for ${functionName}: ${e.message}` + ) + return null + } +} + +function findFunctionInSourceFile(sourceFile, functionName) { + try { + // First, check if this function is re-exported from another module + // If it is, we should NOT try to extract it directly here, but let the re-export resolution handle it + const exportDeclarations = sourceFile.getExportDeclarations() + for (const exportDecl of exportDeclarations) { + const namedExports = exportDecl.getNamedExports() + const hasExport = namedExports.some( + namedExport => namedExport.getName() === functionName + ) + + if (hasExport && exportDecl.getModuleSpecifier()) { + return null // This will force the caller to use re-export resolution + } + } + + const exportedDeclarations = sourceFile.getExportedDeclarations() + if (exportedDeclarations.has(functionName)) { + const declarations = exportedDeclarations.get(functionName) + + for (const declaration of declarations) { + // Skip export declarations - we want actual function implementations + if (Node.isExportDeclaration(declaration)) { + continue + } + + const funcInfo = extractFunctionInfo( + declaration, + functionName, + sourceFile + ) + if (funcInfo) { + return funcInfo + } + } + } + + return null + } catch (e) { + console.warn( + `Error finding function ${functionName} in source file: ${e.message}` + ) + return null + } +} + +function resolveOnFlowPackage(packageName) { + try { + // Look for the package in the workspace packages directory + // We need to account for the fact that the current working directory is already in packages/ + const packagesDir = path.resolve(process.cwd(), "..") + const packageDir = path.join(packagesDir, packageName) + const packageJsonPath = path.join(packageDir, "package.json") + + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) + const entryFile = packageJson.source || packageJson.main || "src/index.ts" + const entryFilePath = path.resolve(packageDir, entryFile) + + if (fs.existsSync(entryFilePath)) { + return entryFilePath + } + } + + return null + } catch (e) { + console.warn(`Error resolving @onflow/${packageName}: ${e.message}`) + return null + } +} + +function resolveReExportedFunction(sourceFile, exportName, moduleSpecifier) { + try { + let referencedSourceFile = null + + // Handle @onflow/ package specifiers + if (moduleSpecifier.startsWith("@onflow/")) { + const packageName = moduleSpecifier.replace("@onflow/", "") + const packageEntryPath = resolveOnFlowPackage(packageName) + + if (packageEntryPath) { + // Get the ts-morph project from the source file + const project = sourceFile.getProject() + + // Try to get the source file, or add it if not already added + referencedSourceFile = project.getSourceFile(packageEntryPath) + if (!referencedSourceFile) { + try { + referencedSourceFile = project.addSourceFileAtPath(packageEntryPath) + } catch (e) { + console.warn( + `Could not add source file at ${packageEntryPath}: ${e.message}` + ) + } + } + } + } else { + // Handle relative imports - existing logic + const referencedSourceFiles = sourceFile.getReferencedSourceFiles() + + // Find the source file that matches the module specifier + for (const sf of referencedSourceFiles) { + const fileName = path.basename(sf.getFilePath(), ".ts") + const moduleFileName = path.basename(moduleSpecifier, ".ts") + + if ( + fileName === moduleFileName || + sf.getFilePath().includes(moduleSpecifier) || + sf.getFilePath().includes(moduleSpecifier.replace("./", "")) + ) { + referencedSourceFile = sf + break + } + } + } + + if (referencedSourceFile) { + const funcInfo = findFunctionInSourceFile( + referencedSourceFile, + exportName + ) + if (funcInfo) { + return funcInfo + } + + // If not found in the entry file, check if it's re-exported from elsewhere + // Look through export declarations to find where this function comes from + const exportDeclarations = referencedSourceFile.getExportDeclarations() + for (const exportDecl of exportDeclarations) { + const namedExports = exportDecl.getNamedExports() + const hasExport = namedExports.some( + namedExport => namedExport.getName() === exportName + ) + + if (hasExport) { + const moduleSpec = exportDecl.getModuleSpecifier() + if (moduleSpec) { + const moduleSpecValue = moduleSpec.getLiteralValue() + // Recursively resolve from the module this export comes from + return resolveReExportedFunction( + referencedSourceFile, + exportName, + moduleSpecValue + ) + } + } + } + } + + return null + } catch (e) { + console.warn( + `Error resolving re-exported function ${exportName} from ${moduleSpecifier}: ${e.message}` + ) + return null + } +} + +module.exports = { + extractFunctionInfo, + findFunctionInSourceFile, + resolveReExportedFunction, +} diff --git a/docs-generator/utils/index.js b/docs-generator/utils/index.js new file mode 100644 index 000000000..6bdd60217 --- /dev/null +++ b/docs-generator/utils/index.js @@ -0,0 +1,22 @@ +const {parseJsDoc} = require("./jsdoc-parser") +const {cleanupTypeText, escapeParameterNameForMDX} = require("./type-utils") +const { + extractFunctionInfo, + findFunctionInSourceFile, + resolveReExportedFunction, +} = require("./function-extractor") +const {extractNamespaceFunctions} = require("./namespace-utils") +const {discoverWorkspacePackages} = require("./file-utils") +const {extractExportsFromEntryFile} = require("./export-extractor") + +module.exports = { + parseJsDoc, + cleanupTypeText, + escapeParameterNameForMDX, + extractFunctionInfo, + findFunctionInSourceFile, + resolveReExportedFunction, + extractNamespaceFunctions, + discoverWorkspacePackages, + extractExportsFromEntryFile, +} diff --git a/docs-generator/utils/jsdoc-parser.js b/docs-generator/utils/jsdoc-parser.js new file mode 100644 index 000000000..1862210b6 --- /dev/null +++ b/docs-generator/utils/jsdoc-parser.js @@ -0,0 +1,215 @@ +function parseJsDoc(node) { + try { + // Try to get JSDoc using the standard API + if (typeof node.getJsDocs === "function") { + const jsDocs = node.getJsDocs() + if (jsDocs && jsDocs.length > 0) { + const jsDoc = jsDocs[0] + + // Parse tags if available + let parsedTags = {} + let description = "" + + if (typeof jsDoc.getTags === "function") { + const tags = jsDoc.getTags() + + tags.forEach(tag => { + const tagName = tag.getTagName() + let comment = "" + + // Try to get comment text + if (typeof tag.getComment === "function") { + comment = tag.getComment() || "" + } + + // Parse different tag types + if (tagName === "description") { + description = comment + } + // Parse param tags + else if (tagName === "param") { + if (!parsedTags.params) parsedTags.params = {} + + // Try to get parameter name from the tag + let paramName = "" + if (typeof tag.getName === "function") { + paramName = tag.getName() + } + + // If no name found, try to extract from comment + if (!paramName && comment) { + const paramMatch = comment.match(/^(\w+[\.\w]*)\s+(.*)$/) + if (paramMatch) { + paramName = paramMatch[1] + comment = paramMatch[2] + } + } + + if (paramName) { + parsedTags.params[paramName] = comment + } + } + // Parse return tag + else if (tagName === "returns" || tagName === "return") { + // Handle multiple @returns tags by concatenating them + if (parsedTags.returns) { + parsedTags.returns += `\n• ${comment}` + } else { + parsedTags.returns = comment + } + } + // Parse example tag + else if (tagName === "example") { + parsedTags.example = comment + } + // Store any other tags + else { + parsedTags[tagName] = comment + } + }) + } + + // If no description from tags, try to get from JSDoc description + if (!description && typeof jsDoc.getDescription === "function") { + description = jsDoc.getDescription() || "" + } + + return { + description: description.trim(), + ...parsedTags, + } + } + } + + // For variable declarations, check the parent VariableStatement for JSDoc + if (typeof node.getParent === "function") { + const parent = node.getParent() + if (parent && typeof parent.getParent === "function") { + const grandparent = parent.getParent() + if (grandparent && typeof grandparent.getJsDocs === "function") { + const parentJsDocs = grandparent.getJsDocs() + if (parentJsDocs && parentJsDocs.length > 0) { + const jsDoc = parentJsDocs[0] + + // Parse tags if available + let parsedTags = {} + let description = "" + + if (typeof jsDoc.getTags === "function") { + const tags = jsDoc.getTags() + + tags.forEach(tag => { + const tagName = tag.getTagName() + let comment = "" + + // Try to get comment text + if (typeof tag.getComment === "function") { + comment = tag.getComment() || "" + } + + // Parse different tag types + if (tagName === "description") { + description = comment + } + // Parse param tags + else if (tagName === "param") { + if (!parsedTags.params) parsedTags.params = {} + + // Try to get parameter name from the tag + let paramName = "" + if (typeof tag.getName === "function") { + paramName = tag.getName() + } + + // If no name found, try to extract from comment + if (!paramName && comment) { + const paramMatch = comment.match(/^(\w+[\.\w]*)\s+(.*)$/) + if (paramMatch) { + paramName = paramMatch[1] + comment = paramMatch[2] + } + } + + if (paramName) { + parsedTags.params[paramName] = comment + } + } + // Parse return tag + else if (tagName === "returns" || tagName === "return") { + // Handle multiple @returns tags by concatenating them + if (parsedTags.returns) { + parsedTags.returns += `\n• ${comment}` + } else { + parsedTags.returns = comment + } + } + // Parse example tag + else if (tagName === "example") { + parsedTags.example = comment + } + // Store any other tags + else { + parsedTags[tagName] = comment + } + }) + } + + // If no description from tags, try to get from JSDoc description + if (!description && typeof jsDoc.getDescription === "function") { + description = jsDoc.getDescription() || "" + } + + return { + description: description.trim(), + ...parsedTags, + } + } + } + } + } + + // Fallback: try to parse JSDoc from the node leading comments + if (typeof node.getLeadingCommentRanges === "function") { + const commentRanges = node.getLeadingCommentRanges() + if (commentRanges && commentRanges.length > 0) { + const commentText = commentRanges + .map(range => range.getText()) + .join("\n") + // Simple regex to extract JSDoc description + const match = /\/\*\*\s*([\s\S]*?)\s*\*\//.exec(commentText) + if (match && match[1]) { + const description = match[1].replace(/^\s*\*\s?/gm, "").trim() + return {description} + } + } + } + + // Also try to get comments from parent node if current node doesn't have any + if (typeof node.getParent === "function") { + const parent = node.getParent() + if (parent && typeof parent.getLeadingCommentRanges === "function") { + const commentRanges = parent.getLeadingCommentRanges() + if (commentRanges && commentRanges.length > 0) { + const commentText = commentRanges + .map(range => range.getText()) + .join("\n") + // Simple regex to extract JSDoc description + const match = /\/\*\*\s*([\s\S]*?)\s*\*\//.exec(commentText) + if (match && match[1]) { + const description = match[1].replace(/^\s*\*\s?/gm, "").trim() + return {description} + } + } + } + } + + return {} + } catch (e) { + console.warn(`Error parsing JSDoc: ${e.message}`) + return {} + } +} + +module.exports = { + parseJsDoc, +} diff --git a/docs-generator/utils/namespace-utils.js b/docs-generator/utils/namespace-utils.js new file mode 100644 index 000000000..dd90bcbcc --- /dev/null +++ b/docs-generator/utils/namespace-utils.js @@ -0,0 +1,92 @@ +const path = require("path") +const {Node} = require("ts-morph") +const {extractFunctionInfo} = require("./function-extractor") + +function extractNamespaceFunctions( + sourceFile, + namespaceName, + importedNamespace +) { + const functions = [] + + try { + // Get the imported namespace source file + const moduleSpecifier = importedNamespace.getModuleSpecifier() + if (!moduleSpecifier) { + console.warn(`No module specifier for namespace ${namespaceName}`) + return functions + } + + const moduleSpecifierValue = moduleSpecifier.getLiteralValue() + + // Skip external packages (those starting with @, or not starting with ./) + if ( + moduleSpecifierValue.startsWith("@") || + (!moduleSpecifierValue.startsWith(".") && + !moduleSpecifierValue.startsWith("/")) + ) { + // This is an external package, silently skip + return functions + } + + // Find the source file using the same logic as resolveReExportedFunction + const referencedSourceFiles = sourceFile.getReferencedSourceFiles() + let namespaceSourceFile = null + + // Find the source file that matches the module specifier + for (const sf of referencedSourceFiles) { + const fileName = path.basename(sf.getFilePath(), ".ts") + const moduleFileName = path.basename(moduleSpecifierValue, ".ts") + + if ( + fileName === moduleFileName || + sf.getFilePath().includes(moduleSpecifierValue) || + sf.getFilePath().includes(moduleSpecifierValue.replace("./", "")) + ) { + namespaceSourceFile = sf + break + } + } + + if (!namespaceSourceFile) { + // Only warn for internal modules (those starting with ./ or ../) + if ( + moduleSpecifierValue.startsWith("./") || + moduleSpecifierValue.startsWith("../") + ) { + console.warn( + `Could not find source file for namespace ${namespaceName} from ${moduleSpecifierValue}` + ) + } + return functions + } + + // Get all exported declarations from the namespace + namespaceSourceFile + .getExportedDeclarations() + .forEach((declarations, name) => { + declarations.forEach(declaration => { + const funcInfo = extractFunctionInfo( + declaration, + name, + namespaceSourceFile, + namespaceName + ) + + if (funcInfo) { + functions.push(funcInfo) + } + }) + }) + } catch (e) { + console.warn( + `Error extracting functions from namespace ${namespaceName}: ${e.message}` + ) + } + + return functions +} + +module.exports = { + extractNamespaceFunctions, +} diff --git a/docs-generator/utils/type-utils.js b/docs-generator/utils/type-utils.js new file mode 100644 index 000000000..a5297afd8 --- /dev/null +++ b/docs-generator/utils/type-utils.js @@ -0,0 +1,48 @@ +function cleanupTypeText(typeText) { + if (!typeText) return typeText + + // Remove import paths and keep only the type name + let cleaned = typeText.replace(/import\("([^"]+)"\)\.([^.\s<>,\[\]]+)/g, "$2") + // Clean up Promise types with imports + cleaned = cleaned.replace( + /Promise,\[\]]+)>/g, + "Promise<$2>" + ) + // Remove any remaining file system paths + cleaned = cleaned.replace(/\/[^"]*\/([^"\/]+)"/g, '"$1"') + // Clean up array types + cleaned = cleaned.replace( + /import\("([^"]+)"\)\.([^.\s<>,\[\]]+)\[\]/g, + "$2[]" + ) + + return cleaned +} + +function toCamelCase(typeName) { + if (!typeName) return typeName + + // Convert PascalCase to camelCase (e.g., AccountProofData -> accountProofData) + return typeName.charAt(0).toLowerCase() + typeName.slice(1) +} + +function escapeParameterNameForMDX(paramName) { + // Don't escape curly braces in parameter names as they are part of destructuring syntax + // and don't need to be escaped in code blocks in MDX + return paramName +} + +function escapeTextForMDX(text) { + if (!text) return text + + // Escape angle brackets to prevent MDX from interpreting TypeScript generics as HTML/JSX tags + // For example: "Promise" becomes "Promise<Interaction>" + return text.replace(//g, ">") +} + +module.exports = { + cleanupTypeText, + toCamelCase, + escapeParameterNameForMDX, + escapeTextForMDX, +} diff --git a/package-lock.json b/package-lock.json index c0e19e814..b9f220e95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.57.1", + "handlebars": "^4.7.8", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "lerna": "^8.1.8", @@ -27,6 +28,7 @@ "prettier-plugin-classnames": "^0.7.3", "prettier-plugin-tailwindcss": "^0.6.8", "ts-jest": "^29.2.5", + "ts-morph": "^21.0.1", "typescript": "^5.6.3" }, "optionalDependencies": { @@ -10463,6 +10465,35 @@ "node": ">=10.13.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.22.0.tgz", + "integrity": "sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.3", + "mkdirp": "^3.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "dev": true, @@ -13467,6 +13498,13 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz", + "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==", + "dev": true, + "license": "MIT" + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "dev": true, @@ -24861,6 +24899,13 @@ "util": "^0.10.3" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "license": "MIT", @@ -29320,6 +29365,17 @@ "node": ">=10" } }, + "node_modules/ts-morph": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-21.0.1.tgz", + "integrity": "sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.22.0", + "code-block-writer": "^12.0.0" + } + }, "node_modules/ts-protoc-gen": { "version": "0.12.0", "dev": true, diff --git a/package.json b/package.json index c3e04d794..c006888ab 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "changeset": "changeset", "clear": "find . -name node_modules -type d -prune -exec rm -rf '{}' + && find . -name dist -type d -prune -exec rm -rf '{}' +", "prettier:check": "prettier --check .", - "prettier": "prettier --write ." + "prettier": "prettier --write .", + "generate-all-docs": "node docs-generator/generate-all-docs.js" }, "name": "fcl-js", "devDependencies": { @@ -22,6 +23,7 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.57.1", + "handlebars": "^4.7.8", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "lerna": "^8.1.8", @@ -29,6 +31,7 @@ "prettier-plugin-classnames": "^0.7.3", "prettier-plugin-tailwindcss": "^0.6.8", "ts-jest": "^29.2.5", + "ts-morph": "^21.0.1", "typescript": "^5.6.3" }, "optionalDependencies": { diff --git a/packages/fcl-core/README.md b/packages/fcl-core/README.md index e8aa388c3..157e9a2d5 100644 --- a/packages/fcl-core/README.md +++ b/packages/fcl-core/README.md @@ -30,7 +30,7 @@ The things that exists probably won't be changing much externally, we will be ad ## Install ```bash -npm install --save @onflow/fcl @onflow/types +npm install --save @onflow/fcl ``` ## Getting Started diff --git a/packages/fcl-core/docs-generator.config.js b/packages/fcl-core/docs-generator.config.js new file mode 100644 index 000000000..28116485e --- /dev/null +++ b/packages/fcl-core/docs-generator.config.js @@ -0,0 +1,10 @@ +const fs = require("fs") +const path = require("path") + +module.exports = { + customData: { + extra: fs + .readFileSync(path.join(__dirname, "docs", "extra.md"), "utf8") + .trim(), + }, +} diff --git a/packages/fcl-core/docs/extra.md b/packages/fcl-core/docs/extra.md new file mode 100644 index 000000000..e13c4366c --- /dev/null +++ b/packages/fcl-core/docs/extra.md @@ -0,0 +1,178 @@ +## Configuration + +FCL has a mechanism that lets you configure various aspects of FCL. When you move from one instance of the Flow Blockchain to another (Local Emulator to Testnet to Mainnet) the only thing you should need to change for your FCL implementation is your configuration. + +### Setting Configuration Values + +Values only need to be set once. We recommend doing this once and as early in the life cycle as possible. To set a configuration value, the `put` method on the `config` instance needs to be called, the `put` method returns the `config` instance so they can be chained. + +Alternatively, you can set the config by passing a JSON object directly. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl + .config() // returns the config instance + .put('foo', 'bar') // configures "foo" to be "bar" + .put('baz', 'buz'); // configures "baz" to be "buz" + +// OR + +fcl.config({ + foo: 'bar', + baz: 'buz', +}); +``` + +### Getting Configuration Values + +The `config` instance has an **asynchronous** `get` method. You can also pass it a fallback value. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl.config().put('foo', 'bar').put('woot', 5).put('rawr', 7); + +const FALLBACK = 1; + +async function addStuff() { + var woot = await fcl.config().get('woot', FALLBACK); // will be 5 -- set in the config before + var rawr = await fcl.config().get('rawr', FALLBACK); // will be 7 -- set in the config before + var hmmm = await fcl.config().get('hmmm', FALLBACK); // will be 1 -- uses fallback because this isnt in the config + + return woot + rawr + hmmm; +} + +addStuff().then((d) => console.log(d)); // 13 (5 + 7 + 1) +``` + +### Common Configuration Keys + +| Name | Example | Description | +| ------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `accessNode.api` **(required)** | `https://rest-testnet.onflow.org` | API URL for the Flow Blockchain Access Node you want to be communicating with. See all available access node endpoints [here](https://developers.onflow.org/http-api/). | +| `app.detail.title` | `Cryptokitties` | Your applications title, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.icon` | `https://fcl-discovery.onflow.org/images/blocto.png` | Url for your applications icon, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.description` | `Cryptokitties is a blockchain game` | Your applications description, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.url` | `https://cryptokitties.co` | Your applications url, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `challenge.handshake` | **DEPRECATED** | Use `discovery.wallet` instead. | +| `discovery.authn.endpoint` | `https://fcl-discovery.onflow.org/api/testnet/authn` | Endpoint for alternative configurable Wallet Discovery mechanism. | +| `discovery.wallet` **(required)** | `https://fcl-discovery.onflow.org/testnet/authn` | Points FCL at the Wallet or Wallet Discovery mechanism. | +| `discovery.wallet.method` | `IFRAME/RPC`, `POP/RPC`, `TAB/RPC`, `HTTP/POST`, or `EXT/RPC` | Describes which service strategy a wallet should use. | +| `fcl.limit` | `100` | Specifies fallback compute limit if not provided in transaction. Provided as integer. | +| `flow.network` **(recommended)** | `testnet` | Used in conjunction with stored interactions and provides FCLCryptoContract address for `testnet` and `mainnet`. Possible values: `local`, `testnet`, `mainnet`. | +| `walletconnect.projectId` | `YOUR_PROJECT_ID` | Your app's WalletConnect project ID. See [WalletConnect Cloud](https://cloud.walletconnect.com/sign-in) to obtain a project ID for your application. | +| `walletconnect.disableNotifications` | `false` | Optional flag to disable pending WalletConnect request notifications within the application's UI. | + +## Using Contracts in Scripts and Transactions + +### Address Replacement + +Configuration keys that start with `0x` will be replaced in FCL scripts and transactions, this allows you to write your script or transaction Cadence code once and not have to change it when you point your application at a difference instance of the Flow Blockchain. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl.config().put('0xFungibleToken', '0xf233dcee88fe0abe'); + +async function myScript() { + return fcl + .send([ + fcl.script` + import FungibleToken from 0xFungibleToken // will be replaced with 0xf233dcee88fe0abe because of the configuration + + access(all) fun main() { /* Rest of the script goes here */ } + `, + ]) + .then(fcl.decode); +} + +async function myTransaction() { + return fcl + .send([ + fcl.transaction` + import FungibleToken from 0xFungibleToken // will be replaced with 0xf233dcee88fe0abe because of the configuration + + transaction { /* Rest of the transaction goes here */ } + `, + ]) + .then(fcl.decode); +} +``` + +#### Example + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl + .config() + .put('flow.network', 'testnet') + .put('walletconnect.projectId', 'YOUR_PROJECT_ID') + .put('accessNode.api', 'https://rest-testnet.onflow.org') + .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn') + .put('app.detail.title', 'Test Harness') + .put('app.detail.icon', 'https://i.imgur.com/r23Zhvu.png') + .put('app.detail.description', 'A test harness for FCL') + .put('app.detail.url', 'https://myapp.com') + .put('service.OpenID.scopes', 'email email_verified name zoneinfo') + .put('0xFlowToken', '0x7e60df042a9c0868'); +``` + +### Using `flow.json` for Contract Imports + +A simpler and more flexible way to manage contract imports in scripts and transactions is by using the `config.load` method in FCL. This lets you load contract configurations from a `flow.json` file, keeping your import syntax clean and allowing FCL to pick the correct contract addresses based on the network you're using. + +#### 1. Define Your Contracts in `flow.json` + +Here’s an example of a `flow.json` file with aliases for multiple networks: + +```json +{ + "contracts": { + "HelloWorld": { + "source": "./cadence/contracts/HelloWorld.cdc", + "aliases": { + "testnet": "0x1cf0e2f2f715450", + "mainnet": "0xf8d6e0586b0a20c7" + } + } + } +} +``` + +- **`source`**: Points to the contract file in your project. +- **`aliases`**: Maps each network to the correct contract address. + +#### 2. Configure FCL + +Load the `flow.json` file and set up FCL to use it: + +```javascript +import { config } from '@onflow/fcl'; +import flowJSON from '../flow.json'; + +config({ + 'flow.network': 'testnet', // Choose your network, e.g., testnet or mainnet + 'accessNode.api': 'https://rest-testnet.onflow.org', // Access node for the network + 'discovery.wallet': `https://fcl-discovery.onflow.org/testnet/authn`, // Wallet discovery +}).load({ flowJSON }); +``` + +With this setup, FCL will automatically use the correct contract address based on the selected network (e.g., `testnet` or `mainnet`). + +#### 3. Use Contract Names in Scripts and Transactions + +After setting up `flow.json`, you can import contracts by name in your Cadence scripts or transactions: + +```cadence +import "HelloWorld" + +access(all) fun main(): String { + return HelloWorld.sayHello() +} +``` + +FCL replaces `"HelloWorld"` with the correct address from the `flow.json` configuration. + +> **Note**: Don’t store private keys in your `flow.json`. Instead, keep sensitive keys in a separate, `.gitignore`-protected file. \ No newline at end of file diff --git a/packages/fcl-core/package.json b/packages/fcl-core/package.json index 95875059b..3821e85c7 100644 --- a/packages/fcl-core/package.json +++ b/packages/fcl-core/package.json @@ -1,7 +1,7 @@ { "name": "@onflow/fcl-core", "version": "1.20.0", - "description": "Flow Client Library", + "description": "Core JavaScript/TypeScript library providing shared functionality for Flow blockchain interactions.", "license": "Apache-2.0", "author": "Flow Foundation", "homepage": "https://flow.com", @@ -44,7 +44,8 @@ "build": "npm run lint && fcl-bundle", "build:types": "tsc --project ./tsconfig-web.json && tsc --noResolve --project ./tsconfig-react-native.json", "start": "fcl-bundle --watch", - "lint": "eslint ." + "lint": "eslint .", + "generate-docs": "node ../../docs-generator/generate-docs.js" }, "dependencies": { "@babel/runtime": "^7.25.7", diff --git a/packages/fcl-core/src/VERSION.js b/packages/fcl-core/src/VERSION.js deleted file mode 100644 index 5883643f2..000000000 --- a/packages/fcl-core/src/VERSION.js +++ /dev/null @@ -1 +0,0 @@ -export const VERSION = PACKAGE_CURRENT_VERSION || "TESTVERSION" diff --git a/packages/fcl-core/src/VERSION.ts b/packages/fcl-core/src/VERSION.ts new file mode 100644 index 000000000..d9ff78b73 --- /dev/null +++ b/packages/fcl-core/src/VERSION.ts @@ -0,0 +1,3 @@ +declare const PACKAGE_CURRENT_VERSION: string | undefined + +export const VERSION: string = PACKAGE_CURRENT_VERSION || "TESTVERSION" diff --git a/packages/fcl-core/src/app-utils/__tests__/verify-user-sig.test.js b/packages/fcl-core/src/app-utils/__tests__/verify-user-sig.test.ts similarity index 94% rename from packages/fcl-core/src/app-utils/__tests__/verify-user-sig.test.js rename to packages/fcl-core/src/app-utils/__tests__/verify-user-sig.test.ts index d5a6376fa..855ef935b 100644 --- a/packages/fcl-core/src/app-utils/__tests__/verify-user-sig.test.js +++ b/packages/fcl-core/src/app-utils/__tests__/verify-user-sig.test.ts @@ -43,7 +43,7 @@ describe("verifyUserSignatures", () => { it("should reject if missing array of composite signatures", async () => { expect.assertions(1) - await expect(verifyUserSignatures(message, null)).rejects.toThrow(Error) + await expect(verifyUserSignatures(message, [])).rejects.toThrow(Error) }) it("should reject if compSigs are from different account addresses", async () => { diff --git a/packages/fcl-core/src/app-utils/index.js b/packages/fcl-core/src/app-utils/index.ts similarity index 80% rename from packages/fcl-core/src/app-utils/index.js rename to packages/fcl-core/src/app-utils/index.ts index ddd4f6784..c9991c130 100644 --- a/packages/fcl-core/src/app-utils/index.js +++ b/packages/fcl-core/src/app-utils/index.ts @@ -1 +1 @@ -export {verifyAccountProof, verifyUserSignatures} from "./verify-signatures.js" +export {verifyAccountProof, verifyUserSignatures} from "./verify-signatures" diff --git a/packages/fcl-core/src/app-utils/verify-signatures.js b/packages/fcl-core/src/app-utils/verify-signatures.js deleted file mode 100644 index 68ec9f5af..000000000 --- a/packages/fcl-core/src/app-utils/verify-signatures.js +++ /dev/null @@ -1,188 +0,0 @@ -import {invariant} from "@onflow/util-invariant" -import {withPrefix, sansPrefix} from "@onflow/util-address" -import {query} from "../exec/query" -import {encodeAccountProof} from "../wallet-utils" -import {isString} from "../utils/is" -import {getChainId} from "../utils" - -const ACCOUNT_PROOF = "ACCOUNT_PROOF" -const USER_SIGNATURE = "USER_SIGNATURE" - -export const validateArgs = args => { - if (args.appIdentifier) { - const {appIdentifier, address, nonce, signatures} = args - invariant( - isString(appIdentifier), - "verifyAccountProof({ appIdentifier }) -- appIdentifier must be a string" - ) - invariant( - isString(address) && sansPrefix(address).length === 16, - "verifyAccountProof({ address }) -- address must be a valid address" - ) - invariant(/^[0-9a-f]+$/i.test(nonce), "nonce must be a hex string") - invariant( - Array.isArray(signatures) && - signatures.every((sig, i, arr) => sig.f_type === "CompositeSignature"), - "Must include an Array of CompositeSignatures to verify" - ) - invariant( - signatures.map(cs => cs.addr).every((addr, i, arr) => addr === arr[0]), - "User signatures to be verified must be from a single account address" - ) - return true - } else { - const {message, address, compSigs} = args - invariant( - /^[0-9a-f]+$/i.test(message), - "Signed message must be a hex string" - ) - invariant( - isString(address) && sansPrefix(address).length === 16, - "verifyUserSignatures({ address }) -- address must be a valid address" - ) - invariant( - Array.isArray(compSigs) && - compSigs.every((sig, i, arr) => sig.f_type === "CompositeSignature"), - "Must include an Array of CompositeSignatures to verify" - ) - invariant( - compSigs.map(cs => cs.addr).every((addr, i, arr) => addr === arr[0]), - "User signatures to be verified must be from a single account address" - ) - return true - } -} - -// TODO: pass in option for contract but we're connected to testnet -// log address + network -> in sync? -const getVerifySignaturesScript = async (sig, opts) => { - const verifyFunction = - sig === "ACCOUNT_PROOF" - ? "verifyAccountProofSignatures" - : "verifyUserSignatures" - - let network = await getChainId(opts) - - const contractAddresses = { - testnet: "0x74daa6f9c7ef24b1", - mainnet: "0xb4b82a1c9d21d284", - previewnet: "0x40b5b8b2ce81ea4a", - } - const fclCryptoContract = opts.fclCryptoContract || contractAddresses[network] - - invariant( - fclCryptoContract, - `${verifyFunction}({ fclCryptoContract }) -- FCLCrypto contract address is unknown for network: ${network}. Please manually specify the FCLCrypto contract address.` - ) - - return ` - import FCLCrypto from ${fclCryptoContract} - - access(all) fun main( - address: Address, - message: String, - keyIndices: [Int], - signatures: [String] - ): Bool { - return FCLCrypto.${verifyFunction}(address: address, message: message, keyIndices: keyIndices, signatures: signatures) - } - ` -} - -/** - * @description - * Verify a valid account proof signature or signatures for an account on Flow. - * - * @param {string} appIdentifier - A message string in hexadecimal format - * @param {object} accountProofData - An object consisting of address, nonce, and signatures - * @param {string} accountProofData.address - A Flow account address - * @param {string} accountProofData.nonce - A random string in hexadecimal format (minimum 32 bytes in total, i.e 64 hex characters) - * @param {object[]} accountProofData.signatures - An array of composite signatures to verify - * @param {object} [opts={}] - Options object - * @param {string} opts.fclCryptoContract - An optional override Flow account address where the FCLCrypto contract is deployed - * @returns {Promise} - Returns true if the signature is valid, false otherwise - * - * @example - * - * const accountProofData = { - * address: "0x123", - * nonce: "F0123" - * signatures: [{f_type: "CompositeSignature", f_vsn: "1.0.0", addr: "0x123", keyId: 0, signature: "abc123"}], - * } - * - * const isValid = await fcl.AppUtils.verifyAccountProof( - * "AwesomeAppId", - * accountProofData, - * {fclCryptoContract} - * ) - */ -export async function verifyAccountProof( - appIdentifier, - {address, nonce, signatures}, - opts = {} -) { - validateArgs({appIdentifier, address, nonce, signatures}) - const message = encodeAccountProof({address, nonce, appIdentifier}, false) - - let signaturesArr = [] - let keyIndices = [] - - for (const el of signatures) { - signaturesArr.push(el.signature) - keyIndices.push(el.keyId.toString()) - } - - return query({ - cadence: await getVerifySignaturesScript(ACCOUNT_PROOF, opts), - args: (arg, t) => [ - arg(withPrefix(address), t.Address), - arg(message, t.String), - arg(keyIndices, t.Array(t.Int)), - arg(signaturesArr, t.Array(t.String)), - ], - }) -} - -/** - * @description - * Verify a valid signature/s for an account on Flow. - * - * @param {string} message - A message string in hexadecimal format - * @param {Array} compSigs - An array of Composite Signatures - * @param {string} compSigs[].addr - The account address - * @param {number} compSigs[].keyId - The account keyId - * @param {string} compSigs[].signature - The signature to verify - * @param {object} [opts={}] - Options object - * @param {string} opts.fclCryptoContract - An optional override of Flow account address where the FCLCrypto contract is deployed - * @returns {Promise} - Returns true if the signature is valid, false otherwise - * - * @example - * - * const isValid = await fcl.AppUtils.verifyUserSignatures( - * Buffer.from('FOO').toString("hex"), - * [{f_type: "CompositeSignature", f_vsn: "1.0.0", addr: "0x123", keyId: 0, signature: "abc123"}], - * {fclCryptoContract} - * ) - */ -export async function verifyUserSignatures(message, compSigs, opts = {}) { - const address = withPrefix(compSigs[0].addr) - validateArgs({message, address, compSigs}) - - let signaturesArr = [] - let keyIndices = [] - - for (const el of compSigs) { - signaturesArr.push(el.signature) - keyIndices.push(el.keyId.toString()) - } - - return query({ - cadence: await getVerifySignaturesScript(USER_SIGNATURE, opts), - args: (arg, t) => [ - arg(address, t.Address), - arg(message, t.String), - arg(keyIndices, t.Array(t.Int)), - arg(signaturesArr, t.Array(t.String)), - ], - }) -} diff --git a/packages/fcl-core/src/app-utils/verify-signatures.ts b/packages/fcl-core/src/app-utils/verify-signatures.ts new file mode 100644 index 000000000..7dc8ef12c --- /dev/null +++ b/packages/fcl-core/src/app-utils/verify-signatures.ts @@ -0,0 +1,299 @@ +import {invariant} from "@onflow/util-invariant" +import {withPrefix, sansPrefix} from "@onflow/util-address" +import {query} from "../exec/query" +import {encodeAccountProof} from "../wallet-utils" +import {isString} from "../utils/is" +import {getChainId} from "../utils" +import {CompositeSignature} from "@onflow/typedefs" + +export interface AccountProofData { + address: string + nonce: string + signatures: CompositeSignature[] +} + +export interface VerifySignaturesScriptOptions { + fclCryptoContract?: string +} + +export interface ValidateArgsInput { + appIdentifier?: string + address?: string + nonce?: string + signatures?: CompositeSignature[] + message?: string + compSigs?: CompositeSignature[] +} + +const ACCOUNT_PROOF = "ACCOUNT_PROOF" +const USER_SIGNATURE = "USER_SIGNATURE" + +/** + * @description Validates input arguments for signature verification functions (both account proof and user signature verification). + * This function performs comprehensive validation of parameters to ensure they meet the requirements for cryptographic + * signature verification on the Flow blockchain. It handles two different validation scenarios: account proof validation + * (when appIdentifier is provided) and user signature validation (when message is provided). + * + * @param args Object containing the arguments to validate. The validation behavior depends on which properties are present: + * - For account proof validation: appIdentifier, address, nonce, and signatures are required + * - For user signature validation: message, address, and compSigs are required + * @param args.appIdentifier Optional unique identifier for the application (triggers account proof validation mode) + * @param args.address Flow account address that should be exactly 16 characters (without 0x prefix) + * @param args.nonce Hexadecimal string representing a cryptographic nonce (for account proof validation) + * @param args.signatures Array of CompositeSignature objects for account proof validation + * @param args.message Hexadecimal string representing the signed message (for user signature validation) + * @param args.compSigs Array of CompositeSignature objects for user signature validation + * + * @returns Always returns true if validation passes, otherwise throws an error + * + * @throws Throws an invariant error if any validation check fails, with specific error messages for each validation failure + * + * @example + * // Validate account proof arguments + * const accountProofArgs = { + * appIdentifier: "MyApp", + * address: "1234567890abcdef", + * nonce: "75f8587e5bd982ec9289c5be1f9426bd", + * signatures: [{ + * f_type: "CompositeSignature", + * f_vsn: "1.0.0", + * addr: "0x1234567890abcdef", + * keyId: 0, + * signature: "abc123def456..." + * }] + * } + * + * const isValid = validateArgs(accountProofArgs) // Returns true or throws + * + * // Validate user signature arguments + * const userSigArgs = { + * message: "48656c6c6f20576f726c64", // "Hello World" in hex + * address: "1234567890abcdef", + * compSigs: [{ + * f_type: "CompositeSignature", + * f_vsn: "1.0.0", + * addr: "0x1234567890abcdef", + * keyId: 0, + * signature: "def456abc123..." + * }] + * } + * + * const isValid = validateArgs(userSigArgs) // Returns true or throws + */ +export const validateArgs = (args: ValidateArgsInput): boolean => { + if (args.appIdentifier) { + const {appIdentifier, address, nonce, signatures} = args + invariant( + isString(appIdentifier), + "verifyAccountProof({ appIdentifier }) -- appIdentifier must be a string" + ) + invariant( + isString(address) && sansPrefix(address!).length === 16, + "verifyAccountProof({ address }) -- address must be a valid address" + ) + invariant(/^[0-9a-f]+$/i.test(nonce!), "nonce must be a hex string") + invariant( + Array.isArray(signatures) && + signatures.every((sig, i, arr) => sig.f_type === "CompositeSignature"), + "Must include an Array of CompositeSignatures to verify" + ) + invariant( + signatures.map(cs => cs.addr).every((addr, i, arr) => addr === arr[0]), + "User signatures to be verified must be from a single account address" + ) + return true + } else { + const {message, address, compSigs} = args + invariant( + /^[0-9a-f]+$/i.test(message!), + "Signed message must be a hex string" + ) + invariant( + isString(address) && sansPrefix(address!).length === 16, + "verifyUserSignatures({ address }) -- address must be a valid address" + ) + invariant( + Array.isArray(compSigs) && + compSigs.every((sig, i, arr) => sig.f_type === "CompositeSignature"), + "Must include an Array of CompositeSignatures to verify" + ) + invariant( + compSigs.map(cs => cs.addr).every((addr, i, arr) => addr === arr[0]), + "User signatures to be verified must be from a single account address" + ) + return true + } +} + +// TODO: pass in option for contract but we're connected to testnet +// log address + network -> in sync? +const getVerifySignaturesScript = async ( + sig: string, + opts: VerifySignaturesScriptOptions +): Promise => { + const verifyFunction = + sig === "ACCOUNT_PROOF" + ? "verifyAccountProofSignatures" + : "verifyUserSignatures" + + const network = await getChainId(opts) + + const contractAddresses: any = { + testnet: "0x74daa6f9c7ef24b1", + mainnet: "0xb4b82a1c9d21d284", + previewnet: "0x40b5b8b2ce81ea4a", + } + const fclCryptoContract = opts.fclCryptoContract || contractAddresses[network] + + invariant( + fclCryptoContract as any, + `${verifyFunction}({ fclCryptoContract }) -- FCLCrypto contract address is unknown for network: ${network}. Please manually specify the FCLCrypto contract address.` + ) + + return ` + import FCLCrypto from ${fclCryptoContract} + + access(all) fun main( + address: Address, + message: String, + keyIndices: [Int], + signatures: [String] + ): Bool { + return FCLCrypto.${verifyFunction}(address: address, message: message, keyIndices: keyIndices, signatures: signatures) + } + ` +} + +/** + * @description Verifies the authenticity of an account proof signature on the Flow blockchain. + * Account proofs are cryptographic signatures used to prove ownership of a Flow account without + * revealing private keys. This function validates that the provided signatures were indeed created + * by the private keys associated with the specified Flow account address. + * + * @param appIdentifier A unique identifier for your application. This is typically + * your app's name or domain and is included in the signed message to prevent replay attacks + * across different applications. + * @param accountProofData Object containing the account proof data to verify + * @param accountProofData.address The Flow account address that allegedly signed the proof + * @param accountProofData.nonce A random hexadecimal string (minimum 32 bytes, 64 hex chars) + * used to prevent replay attacks. Should be unique for each proof request. + * @param accountProofData.signatures Array of composite signatures to verify + * against the account's public keys + * @param opts Optional configuration parameters + * @param opts.fclCryptoContract Override address for the FCLCrypto contract if not using + * the default for the current network + * + * @returns Promise that resolves to true if all signatures are valid, false otherwise. + * + * @returns `true` if verified or `false` + * + * @example + * import * as fcl from "@onflow/fcl" + * + * const accountProofData = { + * address: "0x123", + * nonce: "F0123" + * signatures: [{f_type: "CompositeSignature", f_vsn: "1.0.0", addr: "0x123", keyId: 0, signature: "abc123"}], + * } + * + * const isValid = await fcl.AppUtils.verifyAccountProof( + * "AwesomeAppId", + * accountProofData, + * {fclCryptoContract} + * ) + */ +export async function verifyAccountProof( + appIdentifier: string, + {address, nonce, signatures}: AccountProofData, + opts: VerifySignaturesScriptOptions = {} +): Promise { + validateArgs({appIdentifier, address, nonce, signatures}) + const message = encodeAccountProof({address, nonce, appIdentifier}, false) + + const signaturesArr: string[] = [] + const keyIndices: string[] = [] + + for (const el of signatures) { + signaturesArr.push(el.signature) + keyIndices.push(el.keyId.toString()) + } + + return query({ + cadence: await getVerifySignaturesScript(ACCOUNT_PROOF, opts), + args: (arg: any, t: any) => [ + arg(withPrefix(address), t.Address), + arg(message, t.String), + arg(keyIndices, t.Array(t.Int)), + arg(signaturesArr, t.Array(t.String)), + ], + }) +} + +/** + * @description Verifies user signatures for arbitrary messages on the Flow blockchain. This function + * validates that the provided signatures were created by the private keys associated with the specified + * Flow account when signing the given message. This is useful for authenticating users or validating + * signed data outside of transaction contexts. + * + * @param message The message that was signed, encoded as a hexadecimal string. The original + * message should be converted to hex before passing to this function. + * @param compSigs Array of composite signatures to verify. All signatures + * must be from the same account address. + * @param compSigs[].f_type Must be "CompositeSignature" + * @param compSigs[].f_vsn Must be "1.0.0" + * @param compSigs[].addr The Flow account address that created the signature + * @param compSigs[].keyId The key ID used to create the signature + * @param compSigs[].signature The actual signature data + * @param opts Optional configuration parameters + * @param opts.fclCryptoContract Override address for the FCLCrypto contract + * + * @returns Promise that resolves to true if all signatures are valid, false otherwise + * + * @throws If parameters are invalid, signatures are from different accounts, or network issues occur + * + * @example + * // Basic message signature verification + * import * as fcl from "@onflow/fcl" + * + * const originalMessage = "Hello, Flow blockchain!" + * const hexMessage = Buffer.from(originalMessage).toString("hex") + * + * const signatures = [{ + * f_type: "CompositeSignature", + * f_vsn: "1.0.0", + * addr: "0x1234567890abcdef", + * keyId: 0, + * signature: "abc123def456..." // signature from user's wallet + * }] + * + * const isValid = await fcl.AppUtils.verifyUserSignatures( + * hexMessage, + * signatures + * ) + */ +export async function verifyUserSignatures( + message: string, + compSigs: CompositeSignature[], + opts: VerifySignaturesScriptOptions = {} +): Promise { + const address = withPrefix(compSigs[0].addr) + validateArgs({message, address, compSigs}) + + const signaturesArr: string[] = [] + const keyIndices: string[] = [] + + for (const el of compSigs) { + signaturesArr.push(el.signature) + keyIndices.push(el.keyId.toString()) + } + + return query({ + cadence: await getVerifySignaturesScript(USER_SIGNATURE, opts), + args: (arg, t) => [ + arg(address, t.Address), + arg(message, t.String), + arg(keyIndices, t.Array(t.Int)), + arg(signaturesArr, t.Array(t.String)), + ], + }) +} diff --git a/packages/fcl-core/src/current-user/build-user.js b/packages/fcl-core/src/current-user/build-user.js deleted file mode 100644 index 4c200f26c..000000000 --- a/packages/fcl-core/src/current-user/build-user.js +++ /dev/null @@ -1,44 +0,0 @@ -import {withPrefix} from "@onflow/util-address" -import * as rlp from "@onflow/rlp" -import {fetchServices} from "./fetch-services" -import {mergeServices} from "./merge-services" -import {USER_PRAGMA} from "../normalizers/service/__vsn" -import { - normalizeService, - normalizeServices, -} from "../normalizers/service/service" -import {serviceOfType} from "./service-of-type" - -function deriveCompositeId(authn) { - return rlp - .encode([ - authn.provider?.address || authn.provider?.name || "UNSPECIFIED", - authn.id, - ]) - .toString("hex") -} - -function normalizeData(data) { - data.addr = data.addr ? withPrefix(data.addr) : null - data.paddr = data.paddr ? withPrefix(data.paddr) : null - return data -} - -export async function buildUser(data) { - data = normalizeData(data) - - var services = normalizeServices( - mergeServices(data.services || [], await fetchServices(data.hks, data.code)) - ) - - const authn = serviceOfType(services, "authn") - - return { - ...USER_PRAGMA, - addr: withPrefix(data.addr), - cid: deriveCompositeId(authn), - loggedIn: true, - services: services, - expiresAt: data.expires, - } -} diff --git a/packages/fcl-core/src/current-user/build-user.ts b/packages/fcl-core/src/current-user/build-user.ts new file mode 100644 index 000000000..b6a083dae --- /dev/null +++ b/packages/fcl-core/src/current-user/build-user.ts @@ -0,0 +1,74 @@ +import * as rlp from "@onflow/rlp" +import {CurrentUser, Service} from "@onflow/typedefs" +import {withPrefix} from "@onflow/util-address" +import {USER_PRAGMA} from "../normalizers/service/__vsn" +import {normalizeServices} from "../normalizers/service/service" +import {fetchServices} from "./fetch-services" +import {mergeServices} from "./merge-services" +import {serviceOfType} from "./service-of-type" + +export interface UserData { + addr: string | null + paddr?: string | null + services?: Service[] + hks?: string + code?: string + expires?: number + [key: string]: any +} + +function deriveCompositeId(authn: Service): string { + return rlp + .encode([ + authn.provider?.address || authn.provider?.name || "UNSPECIFIED", + (authn as any).id, + ]) + .toString("hex") +} + +function normalizeData(data: UserData): UserData { + data.addr = data.addr ? withPrefix(data.addr) : null + data.paddr = data.paddr ? withPrefix(data.paddr) : null + return data +} + +/** + * @description Builds a complete CurrentUser object from user data by normalizing addresses, + * fetching additional services, and creating a composite ID. This function handles the + * construction of the user object that represents the authenticated state in FCL. + * + * @param data The user data containing address, services, and authentication information + * @returns Promise resolving to a CurrentUser object with normalized data and services + * + * @example + * // Build a user object from authentication data + * const userData = { + * addr: "0x1234567890abcdef", + * services: [...], + * hks: "https://wallet.example.com/hooks", + * code: "auth_code_123" + * } + * const user = await buildUser(userData) + * console.log(user.addr) // "0x1234567890abcdef" + */ +export async function buildUser(data: UserData): Promise { + data = normalizeData(data) + + var services = normalizeServices( + mergeServices( + data.services || [], + await fetchServices(data.hks!, data.code!) + ) + ) + + const authn = serviceOfType(services, "authn") as Service + + return { + ...USER_PRAGMA, + addr: withPrefix(data.addr!), + cid: deriveCompositeId(authn), + loggedIn: true, + services: services, + expiresAt: data.expires, + } +} diff --git a/packages/fcl-core/src/current-user/exec-service/index.js b/packages/fcl-core/src/current-user/exec-service/index.js deleted file mode 100644 index 1eb69fd4c..000000000 --- a/packages/fcl-core/src/current-user/exec-service/index.js +++ /dev/null @@ -1,87 +0,0 @@ -import {invariant} from "@onflow/util-invariant" -import {log, LEVELS} from "@onflow/util-logger" -import {getServiceRegistry} from "./plugins" -import {getChainId} from "../../utils" -import {VERSION} from "../../VERSION" -import {configLens} from "../../default-config" -import {checkWalletConnectEnabled} from "./wc-check" - -const AbortController = - globalThis.AbortController || require("abort-controller") - -export const execStrategy = async ({ - service, - body, - config, - abortSignal, - customRpc, - user, - opts, -}) => { - const strategy = getServiceRegistry().getStrategy(service.method) - return strategy({service, body, config, abortSignal, customRpc, opts, user}) -} - -export async function execService({ - service, - msg = {}, - config = {}, - opts = {}, - platform, - abortSignal = new AbortController().signal, - execStrategy: _execStrategy, - user, -}) { - // Notify the developer if WalletConnect is not enabled - checkWalletConnectEnabled() - - msg.data = service.data - const execConfig = { - services: await configLens(/^service\./), - app: await configLens(/^app\.detail\./), - client: { - ...config.client, - platform, - fclVersion: VERSION, - fclLibrary: "https://github.com/onflow/fcl-js", - hostname: window?.location?.hostname ?? null, - network: await getChainId(opts), - }, - } - - try { - const res = await (_execStrategy || execStrategy)({ - service, - body: msg, - config: execConfig, - opts, - user, - abortSignal, - }) - - if (res.status === "REDIRECT") { - invariant( - service.type === res.data.type, - "Cannot shift recursive service type in execService" - ) - return await execService({ - service: res.data, - msg, - config: execConfig, - opts, - abortSignal, - platform, - user, - }) - } else { - return res - } - } catch (error) { - log({ - title: `Error on execService ${service?.type}`, - message: error, - level: LEVELS.error, - }) - throw error - } -} diff --git a/packages/fcl-core/src/current-user/exec-service/index.ts b/packages/fcl-core/src/current-user/exec-service/index.ts new file mode 100644 index 000000000..6efef7d35 --- /dev/null +++ b/packages/fcl-core/src/current-user/exec-service/index.ts @@ -0,0 +1,169 @@ +import {invariant} from "@onflow/util-invariant" +import {log, LEVELS} from "@onflow/util-logger" +import {getServiceRegistry} from "./plugins" +import {getChainId} from "../../utils" +import {VERSION} from "../../VERSION" +import {configLens} from "../../default-config" +import {checkWalletConnectEnabled} from "./wc-check" +import {Service, CurrentUser} from "@onflow/typedefs" + +const AbortController = + globalThis.AbortController || require("abort-controller") + +export interface ExecStrategyParams { + service: Service + body: Record + config: ExecConfig + abortSignal: AbortSignal + customRpc?: string + user?: CurrentUser + opts?: Record +} + +export interface ExecServiceParams { + service: Service + msg?: Record + config?: Record + opts?: Record + platform?: string + abortSignal?: AbortSignal + execStrategy?: (params: ExecStrategyParams) => Promise + user?: CurrentUser +} + +export interface StrategyResponse { + status: string + data?: any + updates?: Record + local?: boolean + authorizationUpdates?: Record +} + +export interface ExecConfig { + services: Record + app: Record + client: { + platform?: string + fclVersion: string + fclLibrary: string + hostname: string | null + network: string + [key: string]: any + } +} + +export type StrategyFunction = ( + params: ExecStrategyParams +) => Promise + +/** + * @description Executes a service strategy based on the service method. This function looks up the + * appropriate strategy from the service registry and executes it with the provided parameters. + * It's used internally by FCL to handle different communication methods with wallet services. + * + * @param params The parameters object containing service details and execution context + * @returns Promise resolving to the strategy response + * + * @example + * // Execute a service strategy (internal usage) + * const response = await execStrategy({ + * service: { method: "HTTP/POST", endpoint: "https://wallet.example.com/authz" }, + * body: { transaction: "..." }, + * config: execConfig, + * abortSignal: controller.signal + * }) + */ +export const execStrategy = async ({ + service, + body, + config, + abortSignal, + customRpc, + user, + opts, +}: ExecStrategyParams): Promise => { + const strategy = getServiceRegistry().getStrategy( + service.method + ) as StrategyFunction + return strategy({service, body, config, abortSignal, customRpc, opts, user}) +} + +/** + * @description Executes a service with the provided parameters, handling configuration setup, + * error handling, and recursive service redirects. This is the main entry point for executing + * wallet service interactions in FCL. + * + * @param params The service execution parameters including service, message, and configuration + * @returns Promise resolving to a StrategyResponse containing the execution result + * + * @example + * // Execute a service (internal usage) + * const response = await execService({ + * service: { type: "authz", method: "HTTP/POST", endpoint: "..." }, + * msg: { transaction: "..." }, + * config: { client: { platform: "web" } } + * }) + */ +export async function execService({ + service, + msg = {}, + config = {}, + opts = {}, + platform, + abortSignal = new AbortController().signal, + execStrategy: _execStrategy, + user, +}: ExecServiceParams): Promise { + // Notify the developer if WalletConnect is not enabled + checkWalletConnectEnabled() + + msg.data = service.data + const execConfig: ExecConfig = { + services: await configLens(/^service\./), + app: await configLens(/^app\.detail\./), + client: { + ...config.client, + platform, + fclVersion: VERSION, + fclLibrary: "https://github.com/onflow/fcl-js", + hostname: window?.location?.hostname ?? null, + network: await getChainId(opts), + }, + } + + try { + const res = await (_execStrategy || execStrategy)({ + service, + body: msg, + config: execConfig, + opts, + user, + abortSignal, + }) + + if (res.status === "REDIRECT") { + invariant( + service.type === res.data.type, + "Cannot shift recursive service type in execService" + ) + return await execService({ + service: res.data, + msg, + config: execConfig, + opts, + abortSignal, + platform, + user, + }) + } else { + return res + } + } catch (error: any) { + log({ + title: `Error on execService ${service?.type}`, + message: error, + level: LEVELS.error, + }) + throw error + } +} diff --git a/packages/fcl-core/src/current-user/exec-service/plugins.ts b/packages/fcl-core/src/current-user/exec-service/plugins.ts index 188c3dfc0..314f985e9 100644 --- a/packages/fcl-core/src/current-user/exec-service/plugins.ts +++ b/packages/fcl-core/src/current-user/exec-service/plugins.ts @@ -139,6 +139,25 @@ let serviceRegistry: ReturnType const getIsServiceRegistryInitialized = () => typeof serviceRegistry !== "undefined" +/** + * @description Initializes the service registry with core strategies for different communication methods. + * This function sets up the registry that manages wallet service strategies and should be called once + * during FCL initialization with platform-specific core strategies. + * + * @param params Configuration object containing core strategies + * @param params.coreStrategies Object mapping strategy names to their implementation functions + * @returns The initialized service registry instance + * + * @example + * // Initialize service registry with core strategies + * const registry = initServiceRegistry({ + * coreStrategies: { + * "HTTP/POST": httpPostStrategy, + * "IFRAME/RPC": iframeRpcStrategy, + * "POP/RPC": popupRpcStrategy + * } + * }) + */ export const initServiceRegistry = ({ coreStrategies, }: { @@ -152,6 +171,20 @@ export const initServiceRegistry = ({ return _serviceRegistry } + +/** + * @description Gets the singleton service registry instance. If the registry hasn't been initialized, + * it will be initialized with stub core strategies and a warning will be logged. This function + * provides access to the registry for service and strategy management. + * + * @returns The service registry instance + * + * @example + * // Get the service registry + * const registry = getServiceRegistry() + * const services = registry.getServices() + * const strategy = registry.getStrategy("HTTP/POST") + */ export const getServiceRegistry = () => { if (!getIsServiceRegistryInitialized()) { console.warn( @@ -163,4 +196,20 @@ export const getServiceRegistry = () => { return serviceRegistry } + +/** + * @description Global plugin registry instance for managing FCL plugins. This registry handles + * the registration and management of various FCL plugins including service plugins that add + * new wallet services and strategies. + * + * @example + * // Add a plugin to the registry + * pluginRegistry.add({ + * name: "MyWalletPlugin", + * f_type: "ServicePlugin", + * type: "discovery-service", + * services: [...], + * serviceStrategy: { method: "CUSTOM/RPC", exec: customExecFunction } + * }) + */ export const pluginRegistry = PluginRegistry() diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/http-post.js b/packages/fcl-core/src/current-user/exec-service/strategies/http-post.ts similarity index 60% rename from packages/fcl-core/src/current-user/exec-service/strategies/http-post.js rename to packages/fcl-core/src/current-user/exec-service/strategies/http-post.ts index d93697849..6b525cbd3 100644 --- a/packages/fcl-core/src/current-user/exec-service/strategies/http-post.js +++ b/packages/fcl-core/src/current-user/exec-service/strategies/http-post.ts @@ -4,10 +4,44 @@ import {normalizeLocalView} from "../../../normalizers/service/local-view" import {poll} from "./utils/poll" import {VERSION} from "../../../VERSION" import {serviceEndpoint} from "../strategies/utils/service-endpoint" +import {Service} from "@onflow/typedefs" +export interface ExecHttpPostParams { + service: Service & { + data?: Record + type: string + } + body: Record + config: Record + opts: Record +} + +export type ExecLocalFunction = ( + view: any, + options: { + serviceEndpoint: typeof serviceEndpoint + onClose: () => void + } +) => Promise<[any, () => void]> + +/** + * @description Creates an HTTP POST strategy executor that handles wallet service communication + * via HTTP POST requests. This function manages the full lifecycle including polling for + * responses, handling local views, and managing user interactions. + * + * @param execLocal Function to execute local view rendering and user interaction + * @returns HTTP POST strategy function that can be used to execute services + * + * @example + * // Create an HTTP POST executor + * const httpPostExec = getExecHttpPost(async (view, { serviceEndpoint, onClose }) => { + * // Render local view and return cleanup function + * return [viewData, () => cleanup()] + * }) + */ export const getExecHttpPost = - execLocal => - async ({service, body, config, opts}) => { + (execLocal: ExecLocalFunction) => + async ({service, body, config, opts}: ExecHttpPostParams): Promise => { const resp = await fetchService(service, { data: { fclVersion: VERSION, @@ -21,16 +55,16 @@ export const getExecHttpPost = }, }).then(normalizePollingResponse) - if (resp.status === "APPROVED") { + if (resp?.status === "APPROVED") { return resp.data - } else if (resp.status === "DECLINED") { + } else if (resp?.status === "DECLINED") { throw new Error(`Declined: ${resp.reason || "No reason supplied."}`) - } else if (resp.status === "REDIRECT") { + } else if (resp?.status === "REDIRECT") { return resp - } else if (resp.status === "PENDING") { + } else if (resp?.status === "PENDING") { // these two flags are required to run polling one more time before it stops - var canContinue = true - var shouldContinue = true + let canContinue = true + let shouldContinue = true const [_, unmount] = await execLocal(normalizeLocalView(resp.local), { serviceEndpoint, @@ -45,6 +79,7 @@ export const getExecHttpPost = console.error("Frame Close Error", error) } } + /** * this function is run once per poll call. * Offsetting canContinue flag to make sure that @@ -57,7 +92,6 @@ export const getExecHttpPost = const checkCanContinue = () => { const offsetCanContinue = canContinue canContinue = shouldContinue - return offsetCanContinue } diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/utils/buildMessageHandler.js b/packages/fcl-core/src/current-user/exec-service/strategies/utils/buildMessageHandler.ts similarity index 56% rename from packages/fcl-core/src/current-user/exec-service/strategies/utils/buildMessageHandler.js rename to packages/fcl-core/src/current-user/exec-service/strategies/utils/buildMessageHandler.ts index 96ee2e221..66427d7a2 100644 --- a/packages/fcl-core/src/current-user/exec-service/strategies/utils/buildMessageHandler.js +++ b/packages/fcl-core/src/current-user/exec-service/strategies/utils/buildMessageHandler.ts @@ -1,9 +1,31 @@ +export interface BuildMessageHandlerParams { + close: () => void + send: (msg: any) => void + onReady: ( + e: MessageEvent, + utils: {send: (msg: any) => void; close: () => void} + ) => void + onResponse: ( + e: MessageEvent, + utils: {send: (msg: any) => void; close: () => void} + ) => void + onMessage: ( + e: MessageEvent, + utils: {send: (msg: any) => void; close: () => void} + ) => void + onCustomRpc: ( + payload: any, + utils: {send: (msg: any) => void; close: () => void} + ) => void + getSource?: () => Window | null +} + const CLOSE_EVENT = "FCL:VIEW:CLOSE" const READY_EVENT = "FCL:VIEW:READY" const RESPONSE_EVENT = "FCL:VIEW:RESPONSE" const CUSTOM_RPC = "FCL:VIEW:CUSTOM_RPC" -const _ = e => typeof e === "string" && e.toLowerCase() +const _ = (e: any) => typeof e === "string" && e.toLowerCase() const IGNORE = new Set([ "monetizationstart", @@ -12,12 +34,32 @@ const IGNORE = new Set([ "monetizationstop", ]) -const deprecate = (was, want) => +const deprecate = (was: string, want: string): void => console.warn( "DEPRECATION NOTICE", `Received ${was}, please use ${want} for this and future versions of FCL` ) +/** + * @description Creates a message handler for processing window messages from wallet service + * frames or popups. This handler manages the communication protocol between FCL and wallet + * services, including ready states, responses, and cleanup operations. + * + * @param params Configuration object containing callback functions and utilities + * @returns Message event handler function that can be attached to window message listeners + * + * @example + * // Create a message handler for wallet communication + * const handler = buildMessageHandler({ + * close: () => cleanup(), + * send: (msg) => postMessage(msg), + * onReady: (e, utils) => initializeWallet(utils), + * onResponse: (e, utils) => handleResponse(e.data), + * onMessage: (e, utils) => processMessage(e), + * onCustomRpc: (payload, utils) => handleRpc(payload) + * }) + * window.addEventListener("message", handler) + */ export const buildMessageHandler = ({ close, send, @@ -26,9 +68,9 @@ export const buildMessageHandler = ({ onMessage, onCustomRpc, getSource, -}) => { - let source - return e => { +}: BuildMessageHandlerParams): ((e: MessageEvent) => void) => { + let source: any + return (e: MessageEvent): void => { try { source = getSource?.() || source } catch (_) { @@ -44,7 +86,7 @@ export const buildMessageHandler = ({ if (_(e.data.type) === _(CLOSE_EVENT)) close() if (_(e.data.type) === _(READY_EVENT)) { onReady(e, {send, close}) - source ||= e.source + source ||= e.source as Window } if (_(e.data.type) === _(RESPONSE_EVENT)) onResponse(e, {send, close}) if (_(e.data.type) === _(CUSTOM_RPC)) @@ -55,7 +97,7 @@ export const buildMessageHandler = ({ if (_(e.data.type) === _("FCL:FRAME:READY")) { deprecate(e.data.type, READY_EVENT) onReady(e, {send, close}) - source ||= e.source + source ||= e.source as Window } if (_(e.data.type) === _("FCL:FRAME:RESPONSE")) { deprecate(e.data.type, RESPONSE_EVENT) @@ -73,7 +115,7 @@ export const buildMessageHandler = ({ if (_(e.data.type) === _("FCL::AUTHZ_READY")) { deprecate(e.data.type, READY_EVENT) onReady(e, {send, close}) - source ||= e.source + source ||= e.source as Window } if (_(e.data.type) === _("FCL::CHALLENGE::CANCEL")) { deprecate(e.data.type, CLOSE_EVENT) diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/utils/fetch-service.js b/packages/fcl-core/src/current-user/exec-service/strategies/utils/fetch-service.js deleted file mode 100644 index e8321f2ae..000000000 --- a/packages/fcl-core/src/current-user/exec-service/strategies/utils/fetch-service.js +++ /dev/null @@ -1,19 +0,0 @@ -import {serviceEndpoint} from "./service-endpoint" - -export function fetchService(service, opts = {}) { - const method = opts.method || "POST" - const body = - method === "GET" - ? undefined - : JSON.stringify(opts.data || service.data || {}) - - return fetch(serviceEndpoint(service), { - method: method, - headers: { - ...(service.headers || {}), - ...(opts.headers || {}), - "Content-Type": "application/json", - }, - body: body, - }).then(d => d.json()) -} diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/utils/fetch-service.ts b/packages/fcl-core/src/current-user/exec-service/strategies/utils/fetch-service.ts new file mode 100644 index 000000000..8a0b773dd --- /dev/null +++ b/packages/fcl-core/src/current-user/exec-service/strategies/utils/fetch-service.ts @@ -0,0 +1,46 @@ +import {Service} from "@onflow/typedefs" +import {serviceEndpoint} from "./service-endpoint" + +export interface FetchServiceOptions { + method?: "GET" | "POST" + data?: Record + headers?: Record +} + +/** + * @description Makes an HTTP request to a service endpoint with the specified options. + * This utility function handles the common patterns for communicating with wallet services + * including proper headers, body serialization, and JSON response parsing. + * + * @param service The service configuration containing endpoint and headers + * @param opts Optional request configuration including method, data, and headers + * @returns Promise resolving to the parsed JSON response + * + * @example + * // Fetch from a service endpoint + * const response = await fetchService(service, { + * method: "POST", + * data: { transaction: "..." }, + * headers: { "Authorization": "Bearer token" } + * }) + */ +export function fetchService( + service: Service, + opts: FetchServiceOptions = {} +): Promise { + const method = opts.method || "POST" + const body = + method === "GET" + ? undefined + : JSON.stringify(opts.data || service.data || {}) + + return fetch(serviceEndpoint(service), { + method: method, + headers: { + ...(service.headers || {}), + ...(opts.headers || {}), + "Content-Type": "application/json", + }, + body: body, + }).then(d => d.json()) +} diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/utils/poll.js b/packages/fcl-core/src/current-user/exec-service/strategies/utils/poll.js deleted file mode 100644 index 50cccc301..000000000 --- a/packages/fcl-core/src/current-user/exec-service/strategies/utils/poll.js +++ /dev/null @@ -1,57 +0,0 @@ -import {normalizePollingResponse} from "../../../../normalizers/service/polling-response" -import {invariant} from "@onflow/util-invariant" -import {fetchService} from "./fetch-service" - -const OPTIONS = { - "HTTP/GET": "GET", - "HTTP/POST": "POST", -} - -const serviceMethod = service => { - invariant( - OPTIONS[service.method], - "Invalid Service Method for type back-channel-rpc", - {service} - ) - return OPTIONS[service.method] -} - -const serviceBody = service => { - if (service.method === "HTTP/GET") return undefined - if (service.method === "HTTP/POST" && service.data != null) - return JSON.stringify(service.data) - return undefined -} - -export async function poll(service, checkCanContinue = () => true) { - invariant(service, "Missing Polling Service", {service}) - const canContinue = checkCanContinue() - if (!canContinue) throw new Error("Externally Halted") - - let resp - try { - if ( - typeof document !== "undefined" && - document.visibilityState === "hidden" - ) { - await new Promise(r => setTimeout(r, 500)) - return poll(service, checkCanContinue) - } - - resp = await fetchService(service, { - method: serviceMethod(service), - }).then(normalizePollingResponse) - } catch (error) { - throw error - } - - switch (resp.status) { - case "APPROVED": - return resp.data - case "DECLINED": - throw new Error(`Declined: ${resp.reason || "No reason supplied."}`) - default: - await new Promise(r => setTimeout(r, 500)) - return poll(resp.updates, checkCanContinue) - } -} diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/utils/poll.ts b/packages/fcl-core/src/current-user/exec-service/strategies/utils/poll.ts new file mode 100644 index 000000000..ba48a4987 --- /dev/null +++ b/packages/fcl-core/src/current-user/exec-service/strategies/utils/poll.ts @@ -0,0 +1,80 @@ +import {normalizePollingResponse} from "../../../../normalizers/service/polling-response" +import {invariant} from "@onflow/util-invariant" +import {fetchService} from "./fetch-service" +import {Service} from "@onflow/typedefs" + +export interface ServiceMethodOptions { + "HTTP/GET": "GET" + "HTTP/POST": "POST" +} + +const OPTIONS: ServiceMethodOptions = { + "HTTP/GET": "GET", + "HTTP/POST": "POST", +} + +const serviceMethod = (service: Service): "GET" | "POST" => { + invariant( + OPTIONS[service.method as keyof ServiceMethodOptions] as any, + "Invalid Service Method for type back-channel-rpc", + {service} + ) + return OPTIONS[service.method as keyof ServiceMethodOptions] +} + +const serviceBody = (service: Service): string | undefined => { + if (service.method === "HTTP/GET") return undefined + if (service.method === "HTTP/POST" && (service as any).data != null) + return JSON.stringify((service as any).data) + return undefined +} + +/** + * @description Continuously polls a service endpoint until it receives an APPROVED or DECLINED + * response. This function handles the asynchronous nature of wallet interactions by repeatedly + * checking for status updates with appropriate delays. + * + * @param service The service configuration containing the polling endpoint + * @param checkCanContinue Optional function to control whether polling should continue + * @returns Promise resolving to the final response data when approved or rejected + * + * @example + * // Poll a service for completion + * const result = await poll(pollingService, () => !userCancelled) + * console.log(result) // Final response data + */ +export async function poll( + service: Service, + checkCanContinue: () => boolean = () => true +): Promise { + invariant(service as any, "Missing Polling Service", {service}) + const canContinue = checkCanContinue() + if (!canContinue) throw new Error("Externally Halted") + + let resp + try { + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" + ) { + await new Promise(r => setTimeout(r, 500)) + return poll(service, checkCanContinue) + } + + resp = await fetchService(service, { + method: serviceMethod(service), + }).then(normalizePollingResponse) + } catch (error) { + throw error + } + + switch (resp?.status) { + case "APPROVED": + return resp.data + case "DECLINED": + throw new Error(`Declined: ${resp.reason || "No reason supplied."}`) + default: + await new Promise(r => setTimeout(r, 500)) + return poll(resp?.updates, checkCanContinue) + } +} diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/utils/service-endpoint.js b/packages/fcl-core/src/current-user/exec-service/strategies/utils/service-endpoint.js deleted file mode 100644 index fe8553558..000000000 --- a/packages/fcl-core/src/current-user/exec-service/strategies/utils/service-endpoint.js +++ /dev/null @@ -1,14 +0,0 @@ -import {URL} from "../../../../utils/url" - -export function serviceEndpoint(service) { - const url = new URL(service.endpoint) - if (window?.location?.origin) { - url.searchParams.append("l6n", window.location.origin) - } - if (service.params != null) { - for (let [key, value] of Object.entries(service.params || {})) { - url.searchParams.append(key, value) - } - } - return url -} diff --git a/packages/fcl-core/src/current-user/exec-service/strategies/utils/service-endpoint.ts b/packages/fcl-core/src/current-user/exec-service/strategies/utils/service-endpoint.ts new file mode 100644 index 000000000..9d79234f4 --- /dev/null +++ b/packages/fcl-core/src/current-user/exec-service/strategies/utils/service-endpoint.ts @@ -0,0 +1,36 @@ +import {URL} from "../../../../utils/url" +import {Service} from "@onflow/typedefs" + +/** + * @description Creates a URL object from a service endpoint with additional parameters including + * the application origin and service-specific parameters. This function is used internally by + * FCL strategies to construct the complete URL for service communication. + * + * @param service The service object containing endpoint and optional parameters + * @returns URL object with all parameters appended as query string parameters + * + * @example + * // Create URL from service + * const service = { + * endpoint: "https://wallet.example.com/authn", + * params: { + * appName: "MyApp", + * nonce: "abc123" + * } + * } + * const url = serviceEndpoint(service) + * console.log(url.toString()) + * // https://wallet.example.com/authn?l6n=https://myapp.com&appName=MyApp&nonce=abc123 + */ +export function serviceEndpoint(service: Service): URL { + const url = new URL(service.endpoint) + if (window?.location?.origin) { + url.searchParams.append("l6n", window.location.origin) + } + if (service.params != null) { + for (let [key, value] of Object.entries(service.params || {})) { + url.searchParams.append(key, value) + } + } + return url +} diff --git a/packages/fcl-core/src/current-user/exec-service/wc-check.ts b/packages/fcl-core/src/current-user/exec-service/wc-check.ts index c98891615..fe940093b 100644 --- a/packages/fcl-core/src/current-user/exec-service/wc-check.ts +++ b/packages/fcl-core/src/current-user/exec-service/wc-check.ts @@ -5,6 +5,25 @@ const FCL_WC_SERVICE_METHOD = "WC/RPC" const isServerSide = typeof window === "undefined" +/** + * @description Checks if WalletConnect service plugin is enabled and logs a warning if it's not. + * This function verifies that the WalletConnect strategy is registered in the service registry. + * It's called internally by FCL to notify developers about missing WalletConnect configuration, + * which is required for users to connect with certain wallets. + * + * @example + * // This function is called automatically by FCL, but can be used manually: + * checkWalletConnectEnabled() + * // If WalletConnect is not configured, an error will be logged to the console + * + * // To properly configure WalletConnect to avoid the warning: + * import * as fcl from "@onflow/fcl" + * + * fcl.config({ + * "app.detail.title": "My App", + * "walletconnect.projectId": "your-walletconnect-project-id" + * }) + */ // Utility to notify the user if the Walletconnect service plugin has not been loaded export function checkWalletConnectEnabled() { if (isServerSide) return diff --git a/packages/fcl-core/src/current-user/fetch-services.js b/packages/fcl-core/src/current-user/fetch-services.ts similarity index 50% rename from packages/fcl-core/src/current-user/fetch-services.js rename to packages/fcl-core/src/current-user/fetch-services.ts index 0cb38ea35..0a0114adb 100644 --- a/packages/fcl-core/src/current-user/fetch-services.js +++ b/packages/fcl-core/src/current-user/fetch-services.ts @@ -1,12 +1,32 @@ import {URL} from "../utils/url" - -export async function fetchServices(servicesURL, code) { +import {Service} from "@onflow/typedefs" + +/** + * @description Fetches additional services from a remote endpoint using an authorization code. + * This function handles both modern service arrays and legacy wallet provider formats for + * backward compatibility. + * + * @param servicesURL The URL endpoint to fetch services from + * @param code The authorization code to include in the request + * @returns Promise resolving to an array of Service objects + * + * @example + * // Fetch services from a wallet provider + * const services = await fetchServices( + * "https://wallet.example.com/services", + * "auth_code_123" + * ) + */ +export async function fetchServices( + servicesURL: string | null, + code: string | null +): Promise { if (servicesURL == null || code == null) return [] const url = new URL(servicesURL) url.searchParams.append("code", code) - const resp = await fetch(url, { + const resp: any = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json", @@ -16,7 +36,7 @@ export async function fetchServices(servicesURL, code) { if (Array.isArray(resp)) return resp // Backwards compatibility for First-Gen Wallet Providers - const services = [] + const services: Service[] = [] // Convert authorizations into authz services if (Array.isArray(resp.authorizations)) { diff --git a/packages/fcl-core/src/current-user/index.js b/packages/fcl-core/src/current-user/index.js deleted file mode 100644 index 91e8e4795..000000000 --- a/packages/fcl-core/src/current-user/index.js +++ /dev/null @@ -1,534 +0,0 @@ -import "../default-config" -import * as t from "@onflow/types" -import {arg} from "@onflow/sdk" -import {config} from "@onflow/config" -import {spawn, send, INIT, SUBSCRIBE, UNSUBSCRIBE} from "@onflow/util-actor" -import {withPrefix, sansPrefix} from "@onflow/util-address" -import {invariant} from "@onflow/util-invariant" -import {log, LEVELS} from "@onflow/util-logger" -import {buildUser} from "./build-user" -import {serviceOfType} from "./service-of-type" -import {execService} from "./exec-service" -import {normalizeCompositeSignature} from "../normalizers/service/composite-signature" -import {getDiscoveryService, makeDiscoveryServices} from "../discovery" -import {getServiceRegistry} from "./exec-service/plugins" - -/** - * @typedef {import("@onflow/typedefs").CurrentUser} CurrentUser - * @typedef {import("@onflow/typedefs").CompositeSignature} CompositeSignature - */ - -export const isFn = d => typeof d === "function" - -const NAME = "CURRENT_USER" -const UPDATED = "CURRENT_USER/UPDATED" -const SNAPSHOT = "SNAPSHOT" -const SET_CURRENT_USER = "SET_CURRENT_USER" -const DEL_CURRENT_USER = "DEL_CURRENT_USER" - -const DATA = `{ - "f_type": "User", - "f_vsn": "1.0.0", - "addr":null, - "cid":null, - "loggedIn":null, - "expiresAt":null, - "services":[] -}` - -const getStoredUser = async storage => { - const fallback = JSON.parse(DATA) - const stored = await storage.get(NAME) - if (stored != null && fallback["f_vsn"] !== stored["f_vsn"]) { - storage.removeItem(NAME) - return fallback - } - return stored || fallback -} - -const makeHandlers = cfg => { - // Wrapper for backwards compatibility - const getStorageProvider = async () => { - if (cfg.getStorageProvider) return await cfg.getStorageProvider() - return await config.first(["fcl.storage", "fcl.storage.default"]) - } - - return { - [INIT]: async ctx => { - if (typeof window === "undefined") { - console.warn( - ` - %cFCL Warning - ============================ - "currentUser" is only available in the browser. - For more info, please see the docs: https://docs.onflow.org/fcl/ - ============================ - `, - "font-weight:bold;font-family:monospace;" - ) - } - - ctx.merge(JSON.parse(DATA)) - const storage = await getStorageProvider() - if (storage.can) { - const user = await getStoredUser(storage) - if (notExpired(user)) ctx.merge(user) - } - }, - [SUBSCRIBE]: (ctx, letter) => { - ctx.subscribe(letter.from) - ctx.send(letter.from, UPDATED, {...ctx.all()}) - }, - [UNSUBSCRIBE]: (ctx, letter) => { - ctx.unsubscribe(letter.from) - }, - [SNAPSHOT]: async (ctx, letter) => { - letter.reply({...ctx.all()}) - }, - [SET_CURRENT_USER]: async (ctx, letter, data) => { - ctx.merge(data) - const storage = await getStorageProvider() - if (storage.can) storage.put(NAME, ctx.all()) - ctx.broadcast(UPDATED, {...ctx.all()}) - }, - [DEL_CURRENT_USER]: async (ctx, letter) => { - ctx.merge(JSON.parse(DATA)) - const storage = await getStorageProvider() - if (storage.can) storage.put(NAME, ctx.all()) - ctx.broadcast(UPDATED, {...ctx.all()}) - }, - } -} - -const spawnCurrentUser = cfg => spawn(makeHandlers(cfg), NAME) - -function notExpired(user) { - return ( - user.expiresAt == null || - user.expiresAt === 0 || - user.expiresAt > Date.now() - ) -} - -async function getAccountProofData() { - let accountProofDataResolver = await config.get("fcl.accountProof.resolver") - if (accountProofDataResolver == null) return - if (!isFn(accountProofDataResolver)) { - log({ - title: "Account Proof Data Resolver must be a function", - message: `Check fcl.accountProof.resolver configuration. - Expected: fcl.accountProof.resolver: async () => { ... } - Received: fcl.accountProof.resolver: ${typeof accountProofDataResolver} - `, - level: LEVELS.warn, - }) - return - } - - const accountProofData = {...(await accountProofDataResolver())} - - const origin = window?.location?.origin - - if (accountProofData.appIdentifier) { - if (origin) { - log.deprecate({ - pkg: "FCL", - subject: "appIdentifier in fcl.accountProof.resolver", - message: - "Manually set app identifiers in the account proof resolver function are now deprecated. These are now automatically set to the application origin URL by FCL", - transition: - "https://github.com/onflow/flow-js-sdk/blob/master/packages/fcl/TRANSITIONS.md#0002-deprecate-appIdentifier-field-in-account-proof-resolver", - }) - - invariant( - typeof accountProofData.appIdentifier === "string", - "appIdentifier must be a string" - ) - } - } else { - invariant( - origin, - "The appIdentifier (origin) could not be inferred from the window.location.origin. Please set the appIdentifier manually in the fcl.accountProof.resolver function." - ) - - accountProofData.appIdentifier = origin - } - - invariant( - /^[0-9a-f]+$/i.test(accountProofData.nonce), - "Nonce must be a hex string" - ) - - return accountProofData -} - -const makeConfig = async ({ - discoveryAuthnInclude, - discoveryAuthnExclude, - discoveryFeaturesSuggested, -}) => { - return { - client: { - discoveryAuthnInclude, - discoveryAuthnExclude, - discoveryFeaturesSuggested, - clientServices: await makeDiscoveryServices(), - supportedStrategies: getServiceRegistry().getStrategies(), - }, - } -} - -/** - * @description - Factory function to get the authenticate method - * @param {CurrentUserConfig} config - Current User Configuration - */ -const getAuthenticate = - config => - /** - * @description - Authenticate a user - * @param {object} [opts] - Options - * @param {object} [opts.service] - Optional service to use for authentication - * @param {boolean} [opts.redir] - Optional redirect flag - * @param {boolean} [opts.forceReauth] - Optional force re-authentication flag - * @returns - */ - async ({service, redir = false, forceReauth = false} = {}) => { - if ( - service && - !service?.provider?.is_installed && - service?.provider?.requires_install - ) { - window.location.href = service?.provider?.install_link - return - } - - return new Promise(async (resolve, reject) => { - spawnCurrentUser(config) - const opts = {redir} - const user = await getSnapshot(config)() - const refreshService = serviceOfType(user.services, "authn-refresh") - let accountProofData - - if (user.loggedIn && !forceReauth) { - if (refreshService) { - try { - const response = await execService({ - service: refreshService, - msg: accountProofData, - opts, - platform: config.platform, - user, - }) - send(NAME, SET_CURRENT_USER, await buildUser(response)) - } catch (error) { - log({ - title: `${error.name} Could not refresh wallet authentication.`, - message: error.message, - level: LEVELS.error, - }) - } finally { - return resolve(await getSnapshot(config)()) - } - } else { - return resolve(user) - } - } - - try { - accountProofData = await getAccountProofData() - } catch (error) { - log({ - title: `${error.name} On Authentication: Could not resolve account proof data.`, - message: error.message, - level: LEVELS.error, - }) - return reject(error) - } - - try { - const discoveryService = await getDiscoveryService(service) - const response = await execService({ - service: discoveryService, - msg: accountProofData, - config: await makeConfig(discoveryService), - opts, - platform: config.platform, - execStrategy: config.discovery?.execStrategy, - user, - }) - - send(NAME, SET_CURRENT_USER, await buildUser(response)) - } catch (error) { - log({ - title: `${error} On Authentication`, - message: error, - level: LEVELS.error, - }) - } finally { - resolve(await getSnapshot(config)()) - } - }) - } - -/** - * @description - Factory function to get the unauthenticate method - * @param {CurrentUserConfig} config - Current User Configuration - */ -function getUnauthenticate(config) { - /** - * @description - Unauthenticate a user - */ - return function unauthenticate() { - spawnCurrentUser(config) - send(NAME, DEL_CURRENT_USER) - } -} - -const normalizePreAuthzResponse = authz => ({ - f_type: "PreAuthzResponse", - f_vsn: "1.0.0", - proposer: (authz || {}).proposer, - payer: (authz || {}).payer || [], - authorization: (authz || {}).authorization || [], -}) - -/** - * @description - Factory function to get the resolvePreAuthz method - * @param {CurrentUserConfig} config - Current User Configuration - */ -const getResolvePreAuthz = - config => - (authz, {user}) => { - const resp = normalizePreAuthzResponse(authz) - const axs = [] - - if (resp.proposer != null) axs.push(["PROPOSER", resp.proposer]) - for (let az of resp.payer || []) axs.push(["PAYER", az]) - for (let az of resp.authorization || []) axs.push(["AUTHORIZER", az]) - - var result = axs.map(([role, az]) => ({ - tempId: [az.identity.address, az.identity.keyId].join("|"), - addr: az.identity.address, - keyId: az.identity.keyId, - signingFunction(signable) { - return execService({ - service: az, - msg: signable, - platform: config.platform, - user, - }) - }, - role: { - proposer: role === "PROPOSER", - payer: role === "PAYER", - authorizer: role === "AUTHORIZER", - }, - })) - return result - } - -/** - * @description - Factory function to get the authorization method - * - * @param {CurrentUserConfig} config - Current User Configuration - */ -const getAuthorization = - config => - /** - * @description - Produces the needed authorization details for the current user to submit transactions to Flow - * It defines a signing function that connects to a user's wallet provider to produce signatures to submit transactions. - * - * @param {object} account - Account object - * @returns {Promise} - Account object with signing function - * */ - async account => { - spawnCurrentUser(config) - - return { - ...account, - tempId: "CURRENT_USER", - async resolve(account, preSignable) { - const user = await getAuthenticate(config)({redir: true}) - const authz = serviceOfType(user.services, "authz") - const preAuthz = serviceOfType(user.services, "pre-authz") - - if (preAuthz) - return getResolvePreAuthz(config)( - await execService({ - service: preAuthz, - msg: preSignable, - platform: config.platform, - user, - }), - { - user, - } - ) - if (authz) { - return { - ...account, - tempId: "CURRENT_USER", - resolve: null, - addr: sansPrefix(authz.identity.address), - keyId: authz.identity.keyId, - sequenceNum: null, - signature: null, - async signingFunction(signable) { - return normalizeCompositeSignature( - await execService({ - service: authz, - msg: signable, - opts: { - includeOlderJsonRpcCall: true, - }, - platform: config.platform, - user, - }) - ) - }, - } - } - throw new Error( - "No Authz or PreAuthz Service configured for CURRENT_USER" - ) - }, - } - } - -/** - * @description - Factory function to get the subscribe method - * @param {CurrentUserConfig} config - Current User Configuration - */ -function getSubscribe(config) { - /** - * @description - * The callback passed to subscribe will be called when the user authenticates and un-authenticates, making it easy to update the UI accordingly. - * - * @param {Function} callback - Callback function - * @returns {Function} - Unsubscribe function - */ - return function subscribe(callback) { - spawnCurrentUser(config) - const EXIT = "@EXIT" - const self = spawn(async ctx => { - ctx.send(NAME, SUBSCRIBE) - while (1) { - const letter = await ctx.receive() - if (letter.tag === EXIT) { - ctx.send(NAME, UNSUBSCRIBE) - return - } - callback(letter.data) - } - }) - return () => send(self, EXIT) - } -} - -/** - * @description - Factory function to get the snapshot method - * @param {CurrentUserConfig} config - Current User Configuration - */ -function getSnapshot(config) { - /** - * @description - Gets the current user - * @returns {Promise} - User object - */ - return function snapshot() { - spawnCurrentUser(config) - return send(NAME, SNAPSHOT, null, {expectReply: true, timeout: 0}) - } -} - -/** - * Resolves the current user as an argument - * @param {CurrentUserConfig} config - Current User Configuration - * - */ -const getResolveArgument = config => async () => { - const {addr} = await getAuthenticate(config)() - return arg(withPrefix(addr), t.Address) -} - -const makeSignable = msg => { - invariant(/^[0-9a-f]+$/i.test(msg), "Message must be a hex string") - - return { - message: msg, - } -} - -/** - * @description - Factory function to get the signUserMessage method - * @param {CurrentUserConfig} config - Current User Configuration - */ -const getSignUserMessage = - config => - /** - * @description - A method to use allowing the user to personally sign data via FCL Compatible Wallets/Services. - * @param {string} msg - Message to sign - * @returns {Promise} - Array of CompositeSignatures - */ - async msg => { - spawnCurrentUser(config) - const user = await getAuthenticate(config)({ - redir: true, - }) - - const signingService = serviceOfType(user.services, "user-signature") - - invariant( - signingService, - "Current user must have authorized a signing service." - ) - - try { - const response = await execService({ - service: signingService, - msg: makeSignable(msg), - platform: config.platform, - user, - }) - if (Array.isArray(response)) { - return response.map(compSigs => normalizeCompositeSignature(compSigs)) - } else { - return [normalizeCompositeSignature(response)] - } - } catch (error) { - return error - } - } - -/** - * @typedef {object} CurrentUserConfig - Current User Configuration - * @property {string} platform - Platform - * @property {object} [discovery] - FCL Discovery Configuration - * @property {() => Promise} [getStorageProvider] - Storage Provider Getter - */ - -/** - * @description - * Creates the Current User object - * - * @param {CurrentUserConfig} config - Current User Configuration - * */ -const getCurrentUser = config => { - const currentUser = { - authenticate: getAuthenticate(config), - unauthenticate: getUnauthenticate(config), - authorization: getAuthorization(config), - signUserMessage: getSignUserMessage(config), - subscribe: getSubscribe(config), - snapshot: getSnapshot(config), - resolveArgument: getResolveArgument(config), - } - - return Object.assign( - () => { - return {...currentUser} - }, - {...currentUser} - ) -} - -export {getCurrentUser} diff --git a/packages/fcl-core/src/current-user/index.ts b/packages/fcl-core/src/current-user/index.ts new file mode 100644 index 000000000..96ea868d8 --- /dev/null +++ b/packages/fcl-core/src/current-user/index.ts @@ -0,0 +1,753 @@ +import {config} from "@onflow/config" +import {arg, Signable, t} from "@onflow/sdk" +import { + Account, + CompositeSignature, + CurrentUser, + Service, +} from "@onflow/typedefs" +import { + ActorContext, + INIT, + Letter, + send, + spawn, + SUBSCRIBE, + UNSUBSCRIBE, +} from "@onflow/util-actor" +import {sansPrefix, withPrefix} from "@onflow/util-address" +import {invariant} from "@onflow/util-invariant" +import {LEVELS, log} from "@onflow/util-logger" +import "../default-config" +import {getDiscoveryService, makeDiscoveryServices} from "../discovery" +import {normalizeCompositeSignature} from "../normalizers/service/composite-signature" +import {StorageProvider} from "../utils/storage" +import {buildUser} from "./build-user" +import {execService} from "./exec-service" +import {getServiceRegistry} from "./exec-service/plugins" +import {serviceOfType} from "./service-of-type" + +export interface CurrentUserConfig { + platform: string + discovery?: object | undefined + getStorageProvider?: () => Promise +} + +export interface CurrentUserServiceApi { + authenticate: ({ + service, + redir, + forceReauth, + }?: AuthenticationOptions) => Promise + unauthenticate: () => void + authorization: (account: Account) => Promise + signUserMessage: (msg: string) => Promise + subscribe: (callback: (user: CurrentUser) => void) => () => void + snapshot: () => Promise + resolveArgument: () => Promise +} + +export interface CurrentUserService extends CurrentUserServiceApi { + (): CurrentUserServiceApi +} + +export interface AccountProofData { + appIdentifier: string + nonce: string + [key: string]: any +} + +export interface AuthenticationOptions { + service?: Service + redir?: boolean + forceReauth?: boolean +} + +export interface MakeConfigOptions { + discoveryAuthnInclude?: string[] + discoveryAuthnExclude?: string[] + discoveryFeaturesSuggested?: string[] +} + +/** + * @description Type guard function that checks if a value is a function. This is a simple utility + * used internally by FCL for type checking and validation. + * + * @param d The value to check + * @returns True if the value is a function, false otherwise + * + * @example + * // Check if a value is a function + * const callback = () => console.log("Hello") + * const notCallback = "string" + * + * console.log(isFn(callback)) // true + * console.log(isFn(notCallback)) // false + */ +export const isFn = (d: any): boolean => typeof d === "function" + +const NAME = "CURRENT_USER" +const UPDATED = "CURRENT_USER/UPDATED" +const SNAPSHOT = "SNAPSHOT" +const SET_CURRENT_USER = "SET_CURRENT_USER" +const DEL_CURRENT_USER = "DEL_CURRENT_USER" + +const DATA = `{ + "f_type": "User", + "f_vsn": "1.0.0", + "addr":null, + "cid":null, + "loggedIn":null, + "expiresAt":null, + "services":[] +}` + +const getStoredUser = async (storage: StorageProvider): Promise => { + const fallback = JSON.parse(DATA) + const stored = await storage.get(NAME) + if (stored != null && fallback["f_vsn"] !== stored["f_vsn"]) { + storage.removeItem(NAME) + return fallback + } + return stored || fallback +} + +const makeHandlers = (cfg: CurrentUserConfig) => { + // Wrapper for backwards compatibility + const getStorageProvider = async (): Promise => { + if (cfg.getStorageProvider) return await cfg.getStorageProvider() + return (await config.first( + ["fcl.storage", "fcl.storage.default"], + undefined + )) as any + } + + return { + [INIT]: async (ctx: ActorContext) => { + if (typeof window === "undefined") { + console.warn( + ` + %cFCL Warning + ============================ + "currentUser" is only available in the browser. + For more info, please see the docs: https://docs.onflow.org/fcl/ + ============================ + `, + "font-weight:bold;font-family:monospace;" + ) + } + + ctx.merge(JSON.parse(DATA)) + const storage = await getStorageProvider() + if (storage.can) { + const user = await getStoredUser(storage) + if (notExpired(user)) ctx.merge(user) + } + }, + [SUBSCRIBE]: (ctx: ActorContext, letter: Letter) => { + ctx.subscribe(letter.from) + ctx.send(letter.from, UPDATED, {...ctx.all()}) + }, + [UNSUBSCRIBE]: (ctx: ActorContext, letter: Letter) => { + ctx.unsubscribe(letter.from) + }, + [SNAPSHOT]: async (ctx: ActorContext, letter: Letter) => { + letter.reply({...ctx.all()}) + }, + [SET_CURRENT_USER]: async ( + ctx: ActorContext, + letter: Letter, + data: any + ) => { + ctx.merge(data) + const storage = await getStorageProvider() + if (storage.can) storage.put(NAME, ctx.all()) + ctx.broadcast(UPDATED, {...ctx.all()}) + }, + [DEL_CURRENT_USER]: async (ctx: ActorContext, letter: Letter) => { + ctx.merge(JSON.parse(DATA)) + const storage = await getStorageProvider() + if (storage.can) storage.put(NAME, ctx.all()) + ctx.broadcast(UPDATED, {...ctx.all()}) + }, + } +} + +const spawnCurrentUser = (cfg: CurrentUserConfig) => + spawn(makeHandlers(cfg), NAME) + +function notExpired(user: any): boolean { + return ( + user.expiresAt == null || + user.expiresAt === 0 || + user.expiresAt > Date.now() + ) +} + +async function getAccountProofData(): Promise { + let accountProofDataResolver: any = await config.get( + "fcl.accountProof.resolver" + ) + if (accountProofDataResolver == null) return + if (!isFn(accountProofDataResolver)) { + log({ + title: "Account Proof Data Resolver must be a function", + message: `Check fcl.accountProof.resolver configuration. + Expected: fcl.accountProof.resolver: async () => { ... } + Received: fcl.accountProof.resolver: ${typeof accountProofDataResolver} + `, + level: LEVELS.warn, + }) + return + } + + const accountProofData = {...(await accountProofDataResolver())} + + const origin: any = window?.location?.origin + + if (accountProofData.appIdentifier) { + if (origin) { + log.deprecate({ + pkg: "FCL", + subject: "appIdentifier in fcl.accountProof.resolver", + message: + "Manually set app identifiers in the account proof resolver function are now deprecated. These are now automatically set to the application origin URL by FCL", + transition: + "https://github.com/onflow/flow-js-sdk/blob/master/packages/fcl/TRANSITIONS.md#0002-deprecate-appIdentifier-field-in-account-proof-resolver", + }) + + invariant( + typeof accountProofData.appIdentifier === "string", + "appIdentifier must be a string" + ) + } + } else { + invariant( + origin, + "The appIdentifier (origin) could not be inferred from the window.location.origin. Please set the appIdentifier manually in the fcl.accountProof.resolver function." + ) + + accountProofData.appIdentifier = origin + } + + invariant( + /^[0-9a-f]+$/i.test(accountProofData.nonce), + "Nonce must be a hex string" + ) + + return accountProofData +} + +const makeConfig = async ({ + discoveryAuthnInclude, + discoveryAuthnExclude, + discoveryFeaturesSuggested, +}: MakeConfigOptions): Promise> => { + return { + client: { + discoveryAuthnInclude, + discoveryAuthnExclude, + discoveryFeaturesSuggested, + clientServices: await makeDiscoveryServices(), + supportedStrategies: getServiceRegistry().getStrategies(), + }, + } +} + +/** + * @description Factory function to get the authenticate method + * @param config Current User Configuration + */ +const getAuthenticate = + (config: CurrentUserConfig) => + /** + * @description Calling this method will authenticate the current user via any wallet that supports FCL. Once called, FCL will initiate communication with the configured `discovery.wallet` endpoint which lets the user select a wallet to authenticate with. Once the wallet provider has authenticated the user, FCL will set the values on the current user object for future use and authorization. + * + * This method can only be used in web browsers. + * + * `discovery.wallet` value must be set in the configuration before calling this method. See FCL Configuration. + * + * The default discovery endpoint will open an iframe overlay to let the user choose a supported wallet. + * + * `authenticate` can also take a service returned from discovery with `fcl.authenticate(\{ service \})`. + * + * @param opts Authentication options + * @param opts.service Optional service to use for authentication. A service returned from discovery can be passed here. + * @param opts.redir Optional redirect flag. Defaults to false. + * @param opts.forceReauth Optional force re-authentication flag. Defaults to false. + * @returns Promise that resolves to the authenticated CurrentUser object or undefined + * + * @example + * import * as fcl from '@onflow/fcl'; + * fcl + * .config() + * .put('accessNode.api', 'https://rest-testnet.onflow.org') + * .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn'); + * // anywhere on the page + * fcl.authenticate(); + */ + async ({ + service, + redir = false, + forceReauth = false, + }: AuthenticationOptions = {}): Promise => { + if ( + service && + !service?.provider?.is_installed && + service?.provider?.requires_install + ) { + window.location.href = service?.provider?.install_link! + return + } + + return new Promise(async (resolve, reject) => { + spawnCurrentUser(config) + const opts = {redir} + const user = await getSnapshot(config)() + const refreshService = serviceOfType(user.services, "authn-refresh") + let accountProofData + + if (user.loggedIn && !forceReauth) { + if (refreshService) { + try { + const response: any = await execService({ + service: refreshService, + msg: accountProofData, + opts, + platform: config.platform, + user, + }) + send(NAME, SET_CURRENT_USER, await buildUser(response)) + } catch (error: any) { + log({ + title: `${error.name} Could not refresh wallet authentication.`, + message: error.message, + level: LEVELS.error, + }) + } finally { + return resolve(await getSnapshot(config)()) + } + } else { + return resolve(user) + } + } + + try { + accountProofData = await getAccountProofData() + } catch (error: any) { + log({ + title: `${error.name} On Authentication: Could not resolve account proof data.`, + message: error.message, + level: LEVELS.error, + }) + return reject(error) + } + + try { + const discoveryService = await getDiscoveryService(service) + const response: any = await execService({ + service: discoveryService, + msg: accountProofData, + config: await makeConfig(discoveryService), + opts, + platform: config.platform, + execStrategy: (config.discovery as any)?.execStrategy, + user, + }) + + send(NAME, SET_CURRENT_USER, await buildUser(response)) + } catch (error: any) { + log({ + title: `${error} On Authentication`, + message: error, + level: LEVELS.error, + }) + } finally { + resolve(await getSnapshot(config)()) + } + }) + } + +/** + * @description Factory function to get the unauthenticate method + * @param config Current User Configuration + */ +function getUnauthenticate(config: CurrentUserConfig) { + /** + * @description Logs out the current user and sets the values on the current user object to null. + * + * This method can only be used in web browsers. + * + * The current user must be authenticated first. + * + * @example + * import * as fcl from '@onflow/fcl'; + * fcl.config().put('accessNode.api', 'https://rest-testnet.onflow.org'); + * // first authenticate to set current user + * fcl.authenticate(); + * // ... somewhere else & sometime later + * fcl.unauthenticate(); + * // fcl.currentUser.loggedIn === null + */ + return function unauthenticate() { + spawnCurrentUser(config) + send(NAME, DEL_CURRENT_USER) + } +} + +const normalizePreAuthzResponse = (authz: any) => ({ + f_type: "PreAuthzResponse", + f_vsn: "1.0.0", + proposer: (authz || {}).proposer, + payer: (authz || {}).payer || [], + authorization: (authz || {}).authorization || [], +}) + +/** + * @description Factory function to get the resolvePreAuthz method + * @param config Current User Configuration + */ +const getResolvePreAuthz = + (config: CurrentUserConfig) => + (authz: any, {user}: {user: CurrentUser}) => { + const resp = normalizePreAuthzResponse(authz) + const axs = [] + + if (resp.proposer != null) axs.push(["PROPOSER", resp.proposer]) + for (let az of resp.payer || []) axs.push(["PAYER", az]) + for (let az of resp.authorization || []) axs.push(["AUTHORIZER", az]) + + var result = axs.map(([role, az]) => ({ + tempId: [az.identity.address, az.identity.keyId].join("|"), + addr: az.identity.address, + keyId: az.identity.keyId, + signingFunction(signable: Signable) { + return execService({ + service: az, + msg: signable, + platform: config.platform, + user, + }) + }, + role: { + proposer: role === "PROPOSER", + payer: role === "PAYER", + authorizer: role === "AUTHORIZER", + }, + })) + return result + } + +/** + * @description Factory function to get the authorization method + * @param config Current User Configuration + */ +const getAuthorization = + (config: CurrentUserConfig) => + /** + * @description Produces the needed authorization details for the current user to submit transactions to Flow + * It defines a signing function that connects to a user's wallet provider to produce signatures to submit transactions. + * + * @param account Account object + * @returns Account object with signing function + * */ + async (account: Account) => { + spawnCurrentUser(config) + + return { + ...account, + tempId: "CURRENT_USER", + async resolve(account: Account, preSignable: Signable) { + const user = await getAuthenticate(config)({redir: true}) + const authz = serviceOfType(user!.services, "authz") + const preAuthz = serviceOfType(user!.services, "pre-authz") + + if (preAuthz) + return getResolvePreAuthz(config)( + await execService({ + service: preAuthz, + msg: preSignable, + platform: config.platform, + user, + }), + { + user: user!, + } + ) + if (authz) { + return { + ...account, + tempId: "CURRENT_USER", + resolve: null, + addr: sansPrefix((authz as any).identity.address), + keyId: (authz as any).identity.keyId, + sequenceNum: null, + signature: null, + async signingFunction(signable: Signable) { + return normalizeCompositeSignature( + await execService({ + service: authz, + msg: signable, + opts: { + includeOlderJsonRpcCall: true, + }, + platform: config.platform, + user, + }) + ) + }, + } + } + throw new Error( + "No Authz or PreAuthz Service configured for CURRENT_USER" + ) + }, + } + } + +/** + * @description Factory function to get the subscribe method + * @param config Current User Configuration + */ +function getSubscribe(config: CurrentUserConfig) { + /** + * @description The callback passed to subscribe will be called when the user authenticates and un-authenticates, making it easy to update the UI accordingly. + * + * @param callback The callback will be called with the current user as the first argument when the current user is set or removed. + * @returns Function to unsubscribe from user state changes + * + * @example + * import React, { useState, useEffect } from 'react'; + * import * as fcl from '@onflow/fcl'; + * + * export function AuthCluster() { + * const [user, setUser] = useState({ loggedIn: null }); + * useEffect(() => fcl.currentUser.subscribe(setUser), []); // sets the callback for FCL to use + * + * if (user.loggedIn) { + * return ( + *
+ * {user?.addr ?? 'No Address'} + * + *
+ * ); + * } else { + * return ( + *
+ * {' '} + * + *
+ * ); + * } + * } + */ + return function subscribe(callback: (user: CurrentUser) => void) { + spawnCurrentUser(config) + const EXIT = "@EXIT" + const self = spawn(async ctx => { + ctx.send(NAME, SUBSCRIBE) + while (1) { + const letter = await ctx.receive() + if (letter.tag === EXIT) { + ctx.send(NAME, UNSUBSCRIBE) + return + } + callback(letter.data) + } + }) + return () => send(self, EXIT) + } +} + +/** + * @description Factory function to get the snapshot method + * @param config Current User Configuration + */ +function getSnapshot(config: CurrentUserConfig): () => Promise { + /** + * @description Returns the current user object. This is the same object that is set and available on `fcl.currentUser.subscribe(callback)`. + * + * @returns Promise that resolves to the current user object + * + * @example + * // returns the current user object + * const user = fcl.currentUser.snapshot(); + * + * // subscribes to the current user object and logs to console on changes + * fcl.currentUser.subscribe(console.log); + */ + return function snapshot() { + spawnCurrentUser(config) + return send(NAME, SNAPSHOT, null, {expectReply: true, timeout: 0}) + } +} + +/** + * @description Resolves the current user as an argument + * @param config Current User Configuration + */ +const getResolveArgument = (config: CurrentUserConfig) => async () => { + const {addr} = (await getAuthenticate(config)()) as any + return arg(withPrefix(addr) as any, t.Address) +} + +const makeSignable = (msg: string) => { + invariant(/^[0-9a-f]+$/i.test(msg), "Message must be a hex string") + + return { + message: msg, + } +} + +/** + * @description Factory function to get the signUserMessage method + * @param config Current User Configuration + */ +const getSignUserMessage = + (config: CurrentUserConfig) => + /** + * @description A method to use allowing the user to personally sign data via FCL Compatible Wallets/Services. + * + * This method requires the current user's wallet to support a signing service endpoint. Currently, only Blocto is compatible with this feature by default. + * + * @param msg A hexadecimal string to be signed + * @returns An Array of CompositeSignatures: \{`addr`, `keyId`, `signature`\} + * + * @example + * import * as fcl from '@onflow/fcl'; + * + * export const signMessage = async () => { + * const MSG = Buffer.from('FOO').toString('hex'); + * try { + * return await currentUser.signUserMessage(MSG); + * } catch (error) { + * console.log(error); + * } + * }; + */ + async (msg: string) => { + spawnCurrentUser(config) + const user: any = await getAuthenticate(config)({ + redir: true, + }) + + const signingService = serviceOfType(user.services, "user-signature") + + invariant( + signingService as any, + "Current user must have authorized a signing service." + ) + + try { + const response = await execService({ + service: signingService as any, + msg: makeSignable(msg), + platform: config.platform, + user, + }) + if (Array.isArray(response)) { + return response.map(compSigs => normalizeCompositeSignature(compSigs)) + } else { + return [normalizeCompositeSignature(response)] + } + } catch (error) { + return error + } + } + +/** + * @description Creates and configures the Current User service for managing user authentication and + * authorization in Flow applications. This is the core service for handling user sessions, wallet + * connections, transaction signing, and user data management. The service provides both callable + * function interface and object methods for maximum flexibility. + * + * @param config Configuration object for the current user service + * @param config.platform Platform identifier (e.g., "web", "mobile", "extension") + * @param config.discovery Optional discovery configuration for wallet services + * @param config.getStorageProvider Optional function to provide custom storage implementation + * + * @returns Current user service object with authentication and authorization methods + * + * @example + * // Basic setup and authentication + * import * as fcl from "@onflow/fcl" + * + * // Configure FCL + * fcl.config({ + * "accessNode.api": "https://rest-testnet.onflow.org", + * "discovery.wallet": "https://fcl-discovery.onflow.org/testnet/authn" + * }) + * + * // Create current user service + * const currentUser = fcl.getCurrentUser({ + * platform: "web" + * }) + * + * // Authenticate user + * const user = await currentUser.authenticate() + * console.log("Authenticated user:", user.addr) + * + * // Subscribe to authentication state changes + * const currentUser = fcl.getCurrentUser({ platform: "web" }) + * + * const unsubscribe = currentUser.subscribe((user) => { + * if (user.loggedIn) { + * console.log("User logged in:", user.addr) + * document.getElementById("login-btn").style.display = "none" + * document.getElementById("logout-btn").style.display = "block" + * } else { + * console.log("User logged out") + * document.getElementById("login-btn").style.display = "block" + * document.getElementById("logout-btn").style.display = "none" + * } + * }) + * + * // Clean up subscription + * window.addEventListener("beforeunload", () => unsubscribe()) + * + * // Sign transactions with user authorization + * const currentUser = fcl.getCurrentUser({ platform: "web" }) + * + * const txId = await fcl.mutate({ + * cadence: ` + * transaction(amount: UFix64, to: Address) { + * prepare(signer: AuthAccount) { + * // Transfer tokens logic here + * } + * } + * `, + * args: (arg, t) => [ + * arg("10.0", t.UFix64), + * arg("0x01", t.Address) + * ], + * authz: currentUser.authorization + * }) + * + * // Sign custom messages + * const currentUser = fcl.getCurrentUser({ platform: "web" }) + * + * const message = Buffer.from("Hello, Flow!").toString("hex") + * const signatures = await currentUser.signUserMessage(message) + * + * console.log("Message signatures:", signatures) + */ +const getCurrentUser = (config: CurrentUserConfig): CurrentUserService => { + const currentUser = { + authenticate: getAuthenticate(config), + unauthenticate: getUnauthenticate(config), + authorization: getAuthorization(config), + signUserMessage: getSignUserMessage(config), + subscribe: getSubscribe(config), + snapshot: getSnapshot(config), + resolveArgument: getResolveArgument(config), + } + + return Object.assign( + () => { + return {...currentUser} + }, + {...currentUser} + ) as any +} + +export {getCurrentUser} diff --git a/packages/fcl-core/src/current-user/merge-services.js b/packages/fcl-core/src/current-user/merge-services.js deleted file mode 100644 index 9a705c283..000000000 --- a/packages/fcl-core/src/current-user/merge-services.js +++ /dev/null @@ -1,4 +0,0 @@ -export function mergeServices(sx1 = [], sx2 = []) { - // TODO: Make this smarter - return [...sx1, ...sx2] -} diff --git a/packages/fcl-core/src/current-user/merge-services.ts b/packages/fcl-core/src/current-user/merge-services.ts new file mode 100644 index 000000000..beb6515a9 --- /dev/null +++ b/packages/fcl-core/src/current-user/merge-services.ts @@ -0,0 +1,29 @@ +import {Service} from "@onflow/typedefs" + +/** + * @description Merges two arrays of services into a single array. This is a simple concatenation + * operation used internally by FCL to combine service arrays from different sources. + * The function handles undefined/null inputs gracefully by treating them as empty arrays. + * + * @param sx1 First array of services to merge + * @param sx2 Second array of services to merge + * @returns Combined array containing all services from both input arrays + * + * @example + * // Merge wallet services with discovery services + * const walletServices = [ + * { type: "authn", endpoint: "wallet1.com" }, + * { type: "authz", endpoint: "wallet1.com" } + * ] + * const discoveryServices = [ + * { type: "authn", endpoint: "wallet2.com" } + * ] + * const allServices = mergeServices(walletServices, discoveryServices) + */ +export function mergeServices( + sx1: Service[] = [], + sx2: Service[] = [] +): Service[] { + // TODO: Make this smarter + return [...sx1, ...sx2] +} diff --git a/packages/fcl-core/src/current-user/service-of-type.js b/packages/fcl-core/src/current-user/service-of-type.js deleted file mode 100644 index cd1b2ac4e..000000000 --- a/packages/fcl-core/src/current-user/service-of-type.js +++ /dev/null @@ -1,14 +0,0 @@ -import * as semver from "@onflow/util-semver" - -export function serviceOfType(services = [], type) { - // Find the greatest version of the service type - return services.reduce( - (mostRecent, service) => - service.type === type - ? !mostRecent || semver.compare(service.f_vsn, mostRecent.f_vsn) > 0 - ? service - : mostRecent - : mostRecent, - null - ) -} diff --git a/packages/fcl-core/src/current-user/service-of-type.test.js b/packages/fcl-core/src/current-user/service-of-type.test.ts similarity index 83% rename from packages/fcl-core/src/current-user/service-of-type.test.js rename to packages/fcl-core/src/current-user/service-of-type.test.ts index 0feb567ce..69ec9e55c 100644 --- a/packages/fcl-core/src/current-user/service-of-type.test.js +++ b/packages/fcl-core/src/current-user/service-of-type.test.ts @@ -13,7 +13,7 @@ describe("service-of-type", () => { }, ] - const service = serviceOfType(services, "authn") + const service = serviceOfType(services as any, "authn") expect(service).toEqual({ type: "authn", @@ -33,7 +33,7 @@ describe("service-of-type", () => { }, ] - const service = serviceOfType(services, "non-existent") + const service = serviceOfType(services as any, "non-existent") expect(service).toBe(null) }) diff --git a/packages/fcl-core/src/current-user/service-of-type.ts b/packages/fcl-core/src/current-user/service-of-type.ts new file mode 100644 index 000000000..302a120db --- /dev/null +++ b/packages/fcl-core/src/current-user/service-of-type.ts @@ -0,0 +1,36 @@ +import * as semver from "@onflow/util-semver" +import {Service} from "@onflow/typedefs" + +/** + * @description Finds a service of a specific type from an array of services, returning the one with + * the highest version number. This is used internally by FCL to select the most recent version + * of a service when multiple services of the same type are available. + * + * @param services Array of services to search through + * @param type The type of service to find (e.g., "authn", "authz", "user-signature") + * @returns The service with the highest version number of the specified type, or null if none found + * + * @example + * // Find the latest authentication service + * const services = [ + * { type: "authn", f_vsn: "1.0.0", endpoint: "..." }, + * { type: "authn", f_vsn: "1.1.0", endpoint: "..." }, + * { type: "authz", f_vsn: "1.0.0", endpoint: "..." } + * ] + * const latestAuthn = serviceOfType(services, "authn") + */ +export function serviceOfType( + services: Service[] = [], + type: string +): Service | null { + // Find the greatest version of the service type + return services.reduce( + (mostRecent, service) => + service.type === type + ? !mostRecent || semver.compare(service.f_vsn, mostRecent.f_vsn) > 0 + ? service + : mostRecent + : mostRecent, + null as Service | null + ) +} diff --git a/packages/fcl-core/src/current-user/url-from-service.js b/packages/fcl-core/src/current-user/url-from-service.js deleted file mode 100644 index 2aaa42377..000000000 --- a/packages/fcl-core/src/current-user/url-from-service.js +++ /dev/null @@ -1,11 +0,0 @@ -import {URL} from "../utils/url" - -export function urlFromService(service, includeParams = true) { - const url = new URL(service.endpoint) - if (includeParams) { - for (let [key, value] of Object.entries(service.params || {})) { - url.searchParams.append(key, value) - } - } - return url -} diff --git a/packages/fcl-core/src/current-user/url-from-service.ts b/packages/fcl-core/src/current-user/url-from-service.ts new file mode 100644 index 000000000..ce6d6ae27 --- /dev/null +++ b/packages/fcl-core/src/current-user/url-from-service.ts @@ -0,0 +1,37 @@ +import {URL} from "../utils/url" +import {Service} from "@onflow/typedefs" + +/** + * @description Creates a URL object from a service endpoint, optionally including service parameters + * as query string parameters. This is used internally by FCL to construct URLs for service endpoints + * with their associated parameters. + * + * @param service The service object containing endpoint and optional parameters + * @param includeParams Whether to include service parameters as URL query parameters + * @returns URL object constructed from the service endpoint and parameters + * + * @example + * // Create URL with parameters + * const service = { + * endpoint: "https://wallet.example.com/authn", + * params: { + * appIdentifier: "MyApp", + * nonce: "abc123" + * } + * } + * const url = urlFromService(service, true) + * console.log(url.toString()) + * // "https://wallet.example.com/authn?appIdentifier=MyApp&nonce=abc123" + */ +export function urlFromService( + service: Service, + includeParams: boolean = true +): URL { + const url = new URL(service.endpoint) + if (includeParams) { + for (let [key, value] of Object.entries(service.params || {})) { + url.searchParams.append(key, value) + } + } + return url +} diff --git a/packages/fcl-core/src/default-config.js b/packages/fcl-core/src/default-config.js deleted file mode 100644 index e98da96be..000000000 --- a/packages/fcl-core/src/default-config.js +++ /dev/null @@ -1,10 +0,0 @@ -import {config} from "@onflow/config" - -export async function configLens(regex) { - return Object.fromEntries( - Object.entries(await config().where(regex)).map(([key, value]) => [ - key.replace(regex, ""), - value, - ]) - ) -} diff --git a/packages/fcl-core/src/default-config.ts b/packages/fcl-core/src/default-config.ts new file mode 100644 index 000000000..d9f1b8f9b --- /dev/null +++ b/packages/fcl-core/src/default-config.ts @@ -0,0 +1,28 @@ +import {config} from "@onflow/config" + +/** + * @description Extracts configuration values that match a given regular expression pattern from the Flow configuration. + * This utility function filters the configuration entries using the provided regex pattern and returns a simplified + * object with the matching keys (with the regex pattern removed) and their corresponding values. + * + * @param regex Regular expression pattern to filter configuration keys. The matched portion will be removed from the resulting keys + * @returns Promise that resolves to an object containing the filtered configuration entries with simplified keys + * + * @example + * // Extract all configuration keys starting with "accessNode" + * const accessNodeConfig = await configLens(/^accessNode\./) + * // If config has "accessNode.api" = "https://rest-mainnet.onflow.org" + * // Result: { "api": "https://rest-mainnet.onflow.org" } + * + * // Extract wallet-related configuration + * const walletConfig = await configLens(/^wallet\./) + * // Filters keys like "wallet.discovery.api" and returns simplified object + */ +export async function configLens(regex: RegExp): Promise> { + return Object.fromEntries( + Object.entries(await config().where(regex)).map(([key, value]) => [ + key.replace(regex, ""), + value, + ]) + ) +} diff --git a/packages/fcl-core/src/discovery/index.js b/packages/fcl-core/src/discovery/index.ts similarity index 100% rename from packages/fcl-core/src/discovery/index.js rename to packages/fcl-core/src/discovery/index.ts diff --git a/packages/fcl-core/src/discovery/services.test.js b/packages/fcl-core/src/discovery/services.test.ts similarity index 95% rename from packages/fcl-core/src/discovery/services.test.js rename to packages/fcl-core/src/discovery/services.test.ts index 5690ff899..d899a0053 100644 --- a/packages/fcl-core/src/discovery/services.test.js +++ b/packages/fcl-core/src/discovery/services.test.ts @@ -78,7 +78,6 @@ describe("getServices", () => { afterEach(() => { windowSpy.mockRestore() chainIdSpy.mockRestore() - global.fetch.mockClear() }) it("it should get only services of type authn", async () => { @@ -92,9 +91,9 @@ describe("getServices", () => { Promise.resolve({ json: () => Promise.resolve(mockData), }) - ) + ) as jest.Mock - const response = await getServices({type: ["authn"]}) + const response = await getServices({types: ["authn"]}) expect(global.fetch).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/fcl-core/src/discovery/services.js b/packages/fcl-core/src/discovery/services.ts similarity index 50% rename from packages/fcl-core/src/discovery/services.js rename to packages/fcl-core/src/discovery/services.ts index ef624261e..5faec53c9 100644 --- a/packages/fcl-core/src/discovery/services.js +++ b/packages/fcl-core/src/discovery/services.ts @@ -5,8 +5,42 @@ import {getChainId} from "../utils" import {VERSION} from "../VERSION" import {makeDiscoveryServices} from "./utils" import {URL} from "../utils/url" +import {Service} from "@onflow/typedefs" -export async function getServices({types}) { +export interface GetServicesParams { + types: string[] +} + +export interface DiscoveryRequestBody { + type: string[] + fclVersion: string + include: string[] + exclude: string[] + features: { + suggested: string[] + } + clientServices: Service[] + supportedStrategies: string[] + userAgent?: string + network: string +} + +/** + * @description Fetches available wallet services from the discovery endpoint based on the + * requested service types. This function queries the FCL discovery service to find compatible + * wallet providers that support the specified service types. + * + * @param params Object containing the types of services to discover + * @returns Promise resolving to an array of Service objects from the discovery endpoint + * + * @example + * // Discover authentication services + * const services = await getServices({ types: ["authn"] }) + * console.log(services) // Array of available wallet authentication services + */ +export async function getServices({ + types, +}: GetServicesParams): Promise { const endpoint = await config.get("discovery.authn.endpoint") invariant( Boolean(endpoint), @@ -15,7 +49,7 @@ export async function getServices({types}) { const include = await config.get("discovery.authn.include", []) const exclude = await config.get("discovery.authn.exclude", []) - const url = new URL(endpoint) + const url = new URL(endpoint as string) return fetch(url, { method: "POST", @@ -34,6 +68,6 @@ export async function getServices({types}) { supportedStrategies: getServiceRegistry().getStrategies(), userAgent: window?.navigator?.userAgent, network: await getChainId(), - }), + } as DiscoveryRequestBody), }).then(d => d.json()) } diff --git a/packages/fcl-core/src/discovery/services/authn.js b/packages/fcl-core/src/discovery/services/authn.js deleted file mode 100644 index 899233bce..000000000 --- a/packages/fcl-core/src/discovery/services/authn.js +++ /dev/null @@ -1,124 +0,0 @@ -import { - spawn, - subscriber, - snapshoter, - INIT, - SUBSCRIBE, - UNSUBSCRIBE, - send, -} from "@onflow/util-actor" -import {getServices} from "../services" -import {LEVELS, log} from "@onflow/util-logger" - -export const SERVICE_ACTOR_KEYS = { - AUTHN: "authn", - RESULTS: "results", - SNAPSHOT: "SNAPSHOT", - UPDATED: "UPDATED", - UPDATE_RESULTS: "UPDATE_RESULTS", -} - -const warn = (fact, msg) => { - if (fact) { - console.warn( - ` - %cFCL Warning - ============================ - ${msg} - For more info, please see the docs: https://docs.onflow.org/fcl/ - ============================ - `, - "font-weight:bold;font-family:monospace;" - ) - } -} - -const fetchServicesFromDiscovery = async () => { - try { - const services = await getServices({types: [SERVICE_ACTOR_KEYS.AUTHN]}) - send(SERVICE_ACTOR_KEYS.AUTHN, SERVICE_ACTOR_KEYS.UPDATE_RESULTS, { - results: services, - }) - } catch (error) { - log({ - title: `${error.name} Error fetching Discovery API services.`, - message: error.message, - level: LEVELS.error, - }) - } -} - -const HANDLERS = { - [INIT]: async ctx => { - warn( - typeof window === "undefined", - '"fcl.discovery" is only available in the browser.' - ) - // If you call this before the window is loaded extensions will not be set yet - if (document.readyState === "complete") { - fetchServicesFromDiscovery() - } else { - window.addEventListener("load", () => { - fetchServicesFromDiscovery() - }) - } - }, - [SERVICE_ACTOR_KEYS.UPDATE_RESULTS]: (ctx, _letter, data) => { - ctx.merge(data) - ctx.broadcast(SERVICE_ACTOR_KEYS.UPDATED, {...ctx.all()}) - }, - [SUBSCRIBE]: (ctx, letter) => { - ctx.subscribe(letter.from) - ctx.send(letter.from, SERVICE_ACTOR_KEYS.UPDATED, {...ctx.all()}) - }, - [UNSUBSCRIBE]: (ctx, letter) => ctx.unsubscribe(letter.from), - [SERVICE_ACTOR_KEYS.SNAPSHOT]: async (ctx, letter) => - letter.reply({...ctx.all()}), -} - -const spawnProviders = () => spawn(HANDLERS, SERVICE_ACTOR_KEYS.AUTHN) - -/** - * @typedef {import("@onflow/typedefs").Service} Service - */ - -/** - * @callback SubscriptionCallback - * @returns {Service[]} - */ - -/** - * @description - * Discovery methods for interacting with Authn. - * - * @typedef {object} Authn - * @property {Function} subscribe - Subscribe to Discovery authn services - * @property {Function} snapshot - Get the current Discovery authn services spanshot - * @property {Function} update - Trigger an update of authn services - */ -const authn = { - /** - * @description - Subscribe to Discovery authn services - * @param {Function} cb - * @returns {SubscriptionCallback} - */ - subscribe: cb => subscriber(SERVICE_ACTOR_KEYS.AUTHN, spawnProviders, cb), - /** - * @description - Get the current Discovery authn services spanshot - * @returns {Service[]} - */ - snapshot: () => snapshoter(SERVICE_ACTOR_KEYS.AUTHN, spawnProviders), - /** - * @description - Trigger an update of authn services - * @returns {void} - */ - update: () => { - // Only fetch services if the window is loaded - // Otherwise, this will be called by the INIT handler - if (document.readyState === "complete") { - fetchServicesFromDiscovery() - } - }, -} - -export default authn diff --git a/packages/fcl-core/src/discovery/services/authn.ts b/packages/fcl-core/src/discovery/services/authn.ts new file mode 100644 index 000000000..17ab5934b --- /dev/null +++ b/packages/fcl-core/src/discovery/services/authn.ts @@ -0,0 +1,225 @@ +import { + spawn, + subscriber, + snapshoter, + INIT, + SUBSCRIBE, + UNSUBSCRIBE, + send, + Letter as ActorLetter, + ActorContext, +} from "@onflow/util-actor" +import {getServices} from "../services" +import {LEVELS, log} from "@onflow/util-logger" +import {Service} from "@onflow/typedefs" + +export const SERVICE_ACTOR_KEYS = { + AUTHN: "authn", + RESULTS: "results", + SNAPSHOT: "SNAPSHOT", + UPDATED: "UPDATED", + UPDATE_RESULTS: "UPDATE_RESULTS", +} as const + +export interface ServiceData { + results: Service[] +} + +export type SubscriptionCallback = ( + data: Service[] | null, + error: Error | null +) => void + +export interface Authn { + subscribe: (cb: SubscriptionCallback) => () => void + snapshot: () => Promise + update: () => void +} + +const warn = (fact: boolean, msg: string): void => { + if (fact) { + console.warn( + ` + %cFCL Warning + ============================ + ${msg} + For more info, please see the docs: https://docs.onflow.org/fcl/ + ============================ + `, + "font-weight:bold;font-family:monospace;" + ) + } +} + +const fetchServicesFromDiscovery = async (): Promise => { + try { + const services = await getServices({types: [SERVICE_ACTOR_KEYS.AUTHN]}) + send(SERVICE_ACTOR_KEYS.AUTHN, SERVICE_ACTOR_KEYS.UPDATE_RESULTS, { + results: services, + }) + } catch (error: any) { + log({ + title: `${error.name} Error fetching Discovery API services.`, + message: error.message, + level: LEVELS.error, + }) + } +} + +const HANDLERS = { + [INIT]: async (ctx: ActorContext) => { + warn( + typeof window === "undefined", + '"fcl.discovery" is only available in the browser.' + ) + // If you call this before the window is loaded extensions will not be set yet + if (document.readyState === "complete") { + fetchServicesFromDiscovery() + } else { + window.addEventListener("load", () => { + fetchServicesFromDiscovery() + }) + } + }, + [SERVICE_ACTOR_KEYS.UPDATE_RESULTS]: ( + ctx: ActorContext, + _letter: ActorLetter, + data: ServiceData + ) => { + ctx.merge(data) + ctx.broadcast(SERVICE_ACTOR_KEYS.UPDATED, {...ctx.all()}) + }, + [SUBSCRIBE]: (ctx: ActorContext, letter: ActorLetter) => { + ctx.subscribe(letter.from!) + ctx.send(letter.from!, SERVICE_ACTOR_KEYS.UPDATED, {...ctx.all()}) + }, + [UNSUBSCRIBE]: (ctx: ActorContext, letter: ActorLetter) => + ctx.unsubscribe(letter.from!), + [SERVICE_ACTOR_KEYS.SNAPSHOT]: async ( + ctx: ActorContext, + letter: ActorLetter + ) => letter.reply({...ctx.all()}), +} + +const spawnProviders = () => spawn(HANDLERS as any, SERVICE_ACTOR_KEYS.AUTHN) + +/** + * Discovery authn service for interacting with Flow compatible wallets. + * + * Discovery abstracts away code so that developers don't have to deal with the discovery + * of Flow compatible wallets, integration, or authentication. Using discovery from FCL + * allows dapps to list and authenticate with wallets while having full control over the UI. + * Common use cases for this are login or registration pages. + * + * NOTE: The following methods can only be used in web browsers. + * + * WARNING: discovery.authn.endpoint value MUST be set in the configuration before calling this method. + * + * @example + * // Basic usage with React + * import './config'; + * import { useState, useEffect } from 'react'; + * import * as fcl from '@onflow/fcl'; + * + * function Component() { + * const [wallets, setWallets] = useState([]); + * useEffect( + * () => fcl.discovery.authn.subscribe((res) => setWallets(res.results)), + * [], + * ); + * + * return ( + *
+ * {wallets.map((wallet) => ( + * + * ))} + *
+ * ); + * } + * + * // Configuration for opt-in services + * import { config } from '@onflow/fcl'; + * + * config({ + * 'discovery.authn.endpoint': + * 'https://fcl-discovery.onflow.org/api/testnet/authn', // Endpoint set to Testnet + * 'discovery.authn.include': ['0x9d2e44203cb13051'], // Ledger wallet address on Testnet set to be included + * 'discovery.authn.exclude': ['0x123456789abcdef01'], // Example of excluding a wallet by address + * }); + */ +const authn: Authn = { + /** + * Subscribe to Discovery authn services and receive real-time updates. + * + * This method allows you to subscribe to changes in the available authentication services. + * When new services are discovered or existing ones are updated, the callback function will be invoked. + * + * @param cb Callback function that receives the list of available services and any error + * @returns A function to unsubscribe from the service updates + * + * @example + * import * as fcl from '@onflow/fcl'; + * + * const unsubscribe = fcl.discovery.authn.subscribe((services, error) => { + * if (error) { + * console.error('Discovery error:', error); + * return; + * } + * console.log('Available services:', services); + * }); + * + * // Later, to stop receiving updates + * unsubscribe(); + */ + subscribe: cb => subscriber(SERVICE_ACTOR_KEYS.AUTHN, spawnProviders, cb), + + /** + * Get the current snapshot of Discovery authn services. + * + * This method returns a promise that resolves to the current state of available authentication services + * without setting up a subscription. Useful for one-time checks or initial state loading. + * + * @returns A promise that resolves to the current service data + * + * @example + * import * as fcl from '@onflow/fcl'; + * + * async function getServices() { + * try { + * const serviceData = await fcl.discovery.authn.snapshot(); + * console.log('Current services:', serviceData.results); + * } catch (error) { + * console.error('Failed to get services:', error); + * } + * } + */ + snapshot: () => snapshoter(SERVICE_ACTOR_KEYS.AUTHN, spawnProviders), + + /** + * Trigger an update of authn services from the discovery endpoint. + * + * This method manually triggers a refresh of the available authentication services + * from the configured discovery endpoint. Useful when you want to force a refresh + * of the service list. + * + * @example + * import * as fcl from '@onflow/fcl'; + * + * // Force refresh of available services + * fcl.discovery.authn.update(); + */ + update: () => { + // Only fetch services if the window is loaded + // Otherwise, this will be called by the INIT handler + if (document.readyState === "complete") { + fetchServicesFromDiscovery() + } + }, +} + +export default authn diff --git a/packages/fcl-core/src/discovery/utils.js b/packages/fcl-core/src/discovery/utils.js deleted file mode 100644 index 3bc1c8e8e..000000000 --- a/packages/fcl-core/src/discovery/utils.js +++ /dev/null @@ -1,43 +0,0 @@ -import {config} from "@onflow/config" -import {invariant} from "@onflow/util-invariant" -import {getServiceRegistry} from "../current-user/exec-service/plugins" - -export const makeDiscoveryServices = async () => { - const extensionServices = window?.fcl_extensions || [] - return [...extensionServices, ...getServiceRegistry().getServices()] -} - -export async function getDiscoveryService(service) { - const discoveryAuthnInclude = await config.get("discovery.authn.include", []) - const discoveryAuthnExclude = await config.get("discovery.authn.exclude", []) - const discoveryFeaturesSuggested = await config.get( - "discovery.features.suggested", - [] - ) - const discoveryWalletMethod = await config.first([ - "discovery.wallet.method", - "discovery.wallet.method.default", - ]) - const method = service?.method ? service.method : discoveryWalletMethod - const endpoint = - service?.endpoint ?? - (await config.first(["discovery.wallet", "challenge.handshake"])) - - invariant( - endpoint, - ` - If no service is passed to "authenticate," then "discovery.wallet" must be defined in fcl config. - See: "https://docs.onflow.org/fcl/reference/api/#setting-configuration-values" - ` - ) - - return { - ...service, - type: "authn", - endpoint, - method, - discoveryAuthnInclude, - discoveryAuthnExclude, - discoveryFeaturesSuggested, - } -} diff --git a/packages/fcl-core/src/discovery/utils.ts b/packages/fcl-core/src/discovery/utils.ts new file mode 100644 index 000000000..a7c9559e7 --- /dev/null +++ b/packages/fcl-core/src/discovery/utils.ts @@ -0,0 +1,107 @@ +import {config} from "@onflow/config" +import {invariant} from "@onflow/util-invariant" +import {getServiceRegistry} from "../current-user/exec-service/plugins" +import {Service} from "@onflow/typedefs" + +export interface DiscoveryService extends Service { + discoveryAuthnInclude: string[] + discoveryAuthnExclude: string[] + discoveryFeaturesSuggested: string[] +} + +/** + * @description Creates an array of discovery services by combining extension services from the + * window object with registered services from the service registry. This is used internally + * by FCL to gather all available wallet and authentication services. + * + * @returns Promise that resolves to an array of available services + * + * @example + * // Get all available discovery services + * const services = await makeDiscoveryServices() + * console.log(services.length) // Number of available services + * services.forEach(service => { + * console.log(`Service: ${service.provider?.name}, Type: ${service.type}`) + * }) + */ +export const makeDiscoveryServices = async (): Promise => { + const extensionServices = ((window as any)?.fcl_extensions || []) as Service[] + return [ + ...extensionServices, + ...(getServiceRegistry().getServices() as Service[]), + ] +} + +/** + * @description Creates and configures a discovery service object used for wallet authentication. + * This function combines the provided service configuration with discovery-related settings from + * the FCL configuration to create a complete service definition for wallet authentication flows. + * + * @param service Optional partial service configuration to override defaults + * @param service.method Optional communication method for the service + * @param service.endpoint Optional endpoint URL for the service + * @returns Promise that resolves to a complete discovery service configuration + * @throws Error if required configuration values are missing + * + * @example + * // Get discovery service with default configuration + * const discoveryService = await getDiscoveryService() + * console.log(discoveryService.endpoint) // Configured discovery endpoint + * + * // Override discovery service endpoint + * const customService = await getDiscoveryService({ + * endpoint: "https://wallet.example.com/authn", + * method: "HTTP/POST" + * }) + * + * // Use with custom wallet service + * const walletService = await getDiscoveryService({ + * endpoint: "https://my-wallet.com/fcl", + * provider: { + * name: "My Wallet", + * icon: "https://my-wallet.com/icon.png" + * } + * }) + */ +export async function getDiscoveryService( + service?: Partial +): Promise { + const discoveryAuthnInclude = (await config.get( + "discovery.authn.include", + [] + )) as string[] + const discoveryAuthnExclude = (await config.get( + "discovery.authn.exclude", + [] + )) as string[] + const discoveryFeaturesSuggested = (await config.get( + "discovery.features.suggested", + [] + )) as string[] + const discoveryWalletMethod = await config.first( + ["discovery.wallet.method", "discovery.wallet.method.default"], + undefined + ) + const method = service?.method ? service.method : discoveryWalletMethod + const endpoint = + service?.endpoint ?? + (await config.first(["discovery.wallet", "challenge.handshake"], undefined)) + + invariant( + endpoint as any, + ` + If no service is passed to "authenticate," then "discovery.wallet" must be defined in fcl config. + See: "https://docs.onflow.org/fcl/reference/api/#setting-configuration-values" + ` + ) + + return { + ...service, + type: "authn", + endpoint, + method, + discoveryAuthnInclude, + discoveryAuthnExclude, + discoveryFeaturesSuggested, + } as DiscoveryService +} diff --git a/packages/fcl-core/src/document/document.js b/packages/fcl-core/src/document/document.js deleted file mode 100644 index 23e9110f5..000000000 --- a/packages/fcl-core/src/document/document.js +++ /dev/null @@ -1,58 +0,0 @@ -import {invariant} from "@onflow/util-invariant" -import fetchTransport from "cross-fetch" -import {config} from "@onflow/config" - -async function httpDocumentResolver({url}) { - invariant( - typeof url !== "undefined", - "retrieve({ url }) -- url must be defined" - ) - - let res - try { - res = await fetchTransport(url) - } catch (e) { - throw new Error("httpDocumentResolver Error: Failed to retrieve document.") - } - - let document = res.ok ? await res.json() : null - - return document -} - -const DOCUMENT_RESOLVERS = new Map([ - ["http", httpDocumentResolver], - ["https", httpDocumentResolver], -]) - -export async function retrieve({url}) { - invariant( - typeof url !== "undefined", - "retrieve({ url }) -- url must be defined" - ) - invariant( - typeof url === "string", - "retrieve({ url }) -- url must be a string" - ) - - const documentResolversFromConfig = await config().where( - /^document\.resolver\./ - ) - Object.keys(documentResolversFromConfig).map(key => { - const resolverFromConfig = documentResolversFromConfig[key] - const resolverProtocol = key.replace(/^document\.resolver\./, "") - DOCUMENT_RESOLVERS.set(resolverProtocol, resolverFromConfig) - }) - - const urlParts = /^(.*):\/\/([A-Za-z0-9\-\.]+)(:[0-9]+)?(.*)$/.exec(url) - invariant(urlParts, "Failed to parse URL") - const protocol = urlParts[1] - invariant(urlParts, "Failed to parse URL protocol") - - const resolver = DOCUMENT_RESOLVERS.get(protocol) - invariant(resolver, `No resolver found for protcol=${protocol}`) - - let document = await resolver({url}) - - return document -} diff --git a/packages/fcl-core/src/document/document.test.js b/packages/fcl-core/src/document/document.test.ts similarity index 91% rename from packages/fcl-core/src/document/document.test.js rename to packages/fcl-core/src/document/document.test.ts index ca34d89a5..47928e6e5 100644 --- a/packages/fcl-core/src/document/document.test.js +++ b/packages/fcl-core/src/document/document.test.ts @@ -1,4 +1,4 @@ -import {retrieve} from "./document.js" +import {retrieve} from "./document" import {config} from "@onflow/config" describe("resolveArguments", () => { diff --git a/packages/fcl-core/src/document/document.ts b/packages/fcl-core/src/document/document.ts new file mode 100644 index 000000000..e203e52b2 --- /dev/null +++ b/packages/fcl-core/src/document/document.ts @@ -0,0 +1,88 @@ +import {invariant} from "@onflow/util-invariant" +import fetchTransport from "cross-fetch" +import {config} from "@onflow/config" + +interface DocumentResolverParams { + url: string +} + +export interface RetrieveParams { + url: string +} + +async function httpDocumentResolver({ + url, +}: DocumentResolverParams): Promise { + invariant( + typeof url !== "undefined", + "retrieve({ url }) -- url must be defined" + ) + + let res: Response + try { + res = await fetchTransport(url) + } catch (e) { + throw new Error("httpDocumentResolver Error: Failed to retrieve document.") + } + + let document = res.ok ? await res.json() : null + + return document +} + +const DOCUMENT_RESOLVERS: Map = new Map([ + ["http", httpDocumentResolver], + ["https", httpDocumentResolver], +]) + +/** + * @description Retrieves a document from a URL using protocol-specific resolvers. This function + * supports HTTP/HTTPS by default and can be extended with custom resolvers through FCL configuration. + * It's used internally by FCL to fetch interaction templates and other external documents. + * + * @param params The retrieval parameters + * @param params.url The URL of the document to retrieve + * @returns Promise that resolves to the retrieved document (typically a JSON object) + * @throws {Error} If URL is invalid, protocol is unsupported, or retrieval fails + * + * @example + * // Retrieve an interaction template + * const template = await retrieve({ + * url: "https://flix.flow.com/v1.0/templates/transfer-flow-tokens" + * }) + * console.log("Template:", template) + */ +export async function retrieve({url}: RetrieveParams): Promise { + invariant( + typeof url !== "undefined", + "retrieve({ url }) -- url must be defined" + ) + invariant( + typeof url === "string", + "retrieve({ url }) -- url must be a string" + ) + + const documentResolversFromConfig = await config().where( + /^document\.resolver\./ + ) + Object.keys(documentResolversFromConfig).map(key => { + const resolverFromConfig = documentResolversFromConfig[key] + const resolverProtocol = key.replace(/^document\.resolver\./, "") + DOCUMENT_RESOLVERS.set( + resolverProtocol, + resolverFromConfig as typeof httpDocumentResolver + ) + }) + + const urlParts: any = /^(.*):\/\/([A-Za-z0-9\-\.]+)(:[0-9]+)?(.*)$/.exec(url) + invariant(urlParts, "Failed to parse URL") + const protocol = urlParts[1] + invariant(urlParts, "Failed to parse URL protocol") + + const resolver: any = DOCUMENT_RESOLVERS.get(protocol) + invariant(resolver, `No resolver found for protcol=${protocol}`) + + let document = await resolver({url}) + + return document +} diff --git a/packages/fcl-core/src/events/index.ts b/packages/fcl-core/src/events/index.ts index cfd9ad603..60dafb2ab 100644 --- a/packages/fcl-core/src/events/index.ts +++ b/packages/fcl-core/src/events/index.ts @@ -7,13 +7,61 @@ import {getChainId} from "../utils" const FLOW_EMULATOR = "local" /** - * @description - Subscribe to events - * @param filterOrType - The filter or type of events to subscribe to + * @description Subscribes to Flow blockchain events in real-time. This function provides a way to listen + * for specific events emitted by smart contracts on the Flow blockchain. It automatically handles + * fallback to legacy polling for environments that don't support WebSocket subscriptions. + * + * @param filterOrType Event filter object or event type string. + * If a string is provided, it will be treated as a single event type to subscribe to. + * If an EventFilter object is provided, it can contain multiple event types and other filter criteria. + * @param filterOrType.eventTypes Array of event type strings to subscribe to + * @param filterOrType.startBlockId Block ID to start streaming from + * @param filterOrType.startBlockHeight Block height to start streaming from + * + * @returns An object containing a subscribe method + * @returns returns.subscribe Function to start the subscription + * @returns returns.subscribe.onData Callback function called when an event is received + * @returns returns.subscribe.onError Optional callback function called when an error occurs + * @returns returns.subscribe.unsubscribe Function returned by subscribe() to stop the subscription * * @example + * // Subscribe to a specific event type * import * as fcl from "@onflow/fcl" - * const unsubscribe = fcl.events(eventName).subscribe((event) => console.log(event)) - * unsubscribe() + * + * const unsubscribe = fcl.events("A.0x1654653399040a61.FlowToken.TokensWithdrawn") + * .subscribe((event) => { + * console.log("Event received:", event) + * console.log("Event data:", event.data) + * console.log("Transaction ID:", event.transactionId) + * }) + * + * // Stop listening after 30 seconds + * setTimeout(() => { + * unsubscribe() + * }, 30000) + * + * // Subscribe to multiple event types with error handling + * const unsubscribe = fcl.events({ + * eventTypes: [ + * "A.0x1654653399040a61.FlowToken.TokensWithdrawn", + * "A.0x1654653399040a61.FlowToken.TokensDeposited" + * ] + * }).subscribe( + * (event) => { + * console.log("Token event:", event.type, event.data) + * }, + * (error) => { + * console.error("Event subscription error:", error) + * } + * ) + * + * // Subscribe to events starting from a specific block height + * const unsubscribe = fcl.events({ + * eventTypes: ["A.CONTRACT.EVENT"], + * startBlockHeight: 12345678 + * }).subscribe((event) => { + * console.log("Historical and new events:", event) + * }) */ export function events(filterOrType?: EventFilter | string) { let filter: EventFilter @@ -65,11 +113,13 @@ export function events(filterOrType?: EventFilter | string) { ) } unsubscribeFnLegacy = legacyEvents(filterOrType).subscribe( - (event: Event, error?: Error) => { + (event: Event | null, error: Error | null) => { if (error) { onError(error) } else { - onData(event) + if (event) { + onData(event) + } } } ) diff --git a/packages/fcl-core/src/events/legacy-events.js b/packages/fcl-core/src/events/legacy-events.js deleted file mode 100644 index 0a7fba8b7..000000000 --- a/packages/fcl-core/src/events/legacy-events.js +++ /dev/null @@ -1,91 +0,0 @@ -import {spawn, subscriber, SUBSCRIBE, UNSUBSCRIBE} from "@onflow/util-actor" -import { - config, - block, - getEventsAtBlockHeightRange, - send, - decode, -} from "@onflow/sdk" - -const RATE = 10000 -const UPDATED = "UPDATED" -const TICK = "TICK" -const HIGH_WATER_MARK = "hwm" - -const scheduleTick = async ctx => { - return setTimeout( - () => ctx.sendSelf(TICK), - await config().get("fcl.eventPollRate", RATE) - ) -} - -const HANDLERS = { - [TICK]: async ctx => { - if (!ctx.hasSubs()) return - let hwm = ctx.get(HIGH_WATER_MARK) - if (hwm == null) { - ctx.put(HIGH_WATER_MARK, await block()) - ctx.put(TICK, await scheduleTick(ctx)) - } else { - let next = await block() - ctx.put(HIGH_WATER_MARK, next) - if (hwm.height < next.height) { - const data = await send([ - getEventsAtBlockHeightRange(ctx.self(), hwm.height + 1, next.height), - ]).then(decode) - for (let d of data) ctx.broadcast(UPDATED, d) - } - ctx.put(TICK, await scheduleTick(ctx)) - } - }, - [SUBSCRIBE]: async (ctx, letter) => { - if (!ctx.hasSubs()) { - ctx.put(TICK, await scheduleTick(ctx)) - } - ctx.subscribe(letter.from) - }, - [UNSUBSCRIBE]: (ctx, letter) => { - ctx.unsubscribe(letter.from) - if (!ctx.hasSubs()) { - clearTimeout(ctx.get(TICK)) - ctx.delete(TICK) - ctx.delete(HIGH_WATER_MARK) - } - }, -} - -const spawnEvents = key => spawn(HANDLERS, key) - -/** - * @typedef {import("@onflow/typedefs").Event} Event - */ - -/** - * @typedef {object} SubscribeObject - * @property {Function} subscribe - The subscribe function. - */ - -/** - * @callback SubscriptionCallback - * @returns {Event} - */ - -/** - * @description - Subscribe to events - * @param {string} key - A valid event name - * @returns {SubscribeObject} - * - * @example - * import * as fcl from "@onflow/fcl" - * fcl.events(eventName).subscribe((event) => console.log(event)) - */ -export function events(key) { - return { - /** - * @description - Subscribe to events - * @param {Function} callback - The callback function - * @returns {SubscriptionCallback} - */ - subscribe: callback => subscriber(key, spawnEvents, callback), - } -} diff --git a/packages/fcl-core/src/events/legacy-events.ts b/packages/fcl-core/src/events/legacy-events.ts new file mode 100644 index 000000000..e13566811 --- /dev/null +++ b/packages/fcl-core/src/events/legacy-events.ts @@ -0,0 +1,97 @@ +import { + block, + config, + decode, + getEventsAtBlockHeightRange, + send, +} from "@onflow/sdk" +import type {Block, Event} from "@onflow/typedefs" +import { + ActorContext, + ActorHandlers, + Letter, + spawn, + SUBSCRIBE, + subscriber, + UNSUBSCRIBE, +} from "@onflow/util-actor" + +export interface SubscribeObject { + /** + * @description Subscribe to events + * @param callback The callback function + * @returns A function to unsubscribe + */ + subscribe: ( + callback: (data: Event | null, error: Error | null) => void + ) => () => void +} + +const RATE: number = 10000 +const UPDATED: string = "UPDATED" +const TICK: string = "TICK" +const HIGH_WATER_MARK: string = "hwm" + +const scheduleTick = async (ctx: ActorContext): Promise => { + return setTimeout( + () => ctx.sendSelf(TICK), + await config().get("fcl.eventPollRate", RATE) + ) +} + +const HANDLERS: ActorHandlers = { + [TICK]: async (ctx: ActorContext): Promise => { + if (!ctx.hasSubs()) return + let hwm: Block | null = ctx.get(HIGH_WATER_MARK) + if (hwm == null) { + ctx.put(HIGH_WATER_MARK, await block()) + ctx.put(TICK, await scheduleTick(ctx)) + } else { + let next: Block = await block() + ctx.put(HIGH_WATER_MARK, next) + if (hwm.height < next.height) { + const data: Event[] = await send([ + getEventsAtBlockHeightRange(ctx.self(), hwm.height + 1, next.height), + ]).then(decode) + for (let d of data) ctx.broadcast(UPDATED, d) + } + ctx.put(TICK, await scheduleTick(ctx)) + } + }, + [SUBSCRIBE]: async (ctx: ActorContext, letter: Letter): Promise => { + if (!ctx.hasSubs()) { + ctx.put(TICK, await scheduleTick(ctx)) + } + ctx.subscribe(letter.from) + }, + [UNSUBSCRIBE]: (ctx: ActorContext, letter: Letter): void => { + ctx.unsubscribe(letter.from) + if (!ctx.hasSubs()) { + clearTimeout(ctx.get(TICK)) + ctx.delete(TICK) + ctx.delete(HIGH_WATER_MARK) + } + }, +} + +const spawnEvents = (key?: string): string => spawn(HANDLERS, key) + +/** + * @description Subscribe to events + * @param key A valid event name + * @returns An object with a subscribe method + * + * @example + * import * as fcl from "@onflow/fcl" + * fcl.events(eventName).subscribe((event) => console.log(event)) + */ +export function events(key: string): SubscribeObject { + return { + /** + * @description Subscribe to events + * @param {Function} callback The callback function + * @returns {SubscriptionCallback} + */ + subscribe: callback => subscriber(key, spawnEvents, callback), + } +} diff --git a/packages/fcl-core/src/exec/args.ts b/packages/fcl-core/src/exec/args.ts index e52956616..125069690 100644 --- a/packages/fcl-core/src/exec/args.ts +++ b/packages/fcl-core/src/exec/args.ts @@ -3,9 +3,12 @@ import * as t from "@onflow/types" type ArgFn = typeof arg type Types = typeof t + /** - * @param arg - Argument function to define a single argument - * @param t - Cadence Types object used to define the type - * @returns {any[]} + * @description Type definition for argument functions used to define transaction and script arguments + * + * @param arg Argument function to define a single argument + * @param t Cadence Types object used to define the type + * @returns Array of arguments */ export type ArgsFn = (arg: ArgFn, t: Types) => any[] diff --git a/packages/fcl-core/src/exec/mutate.js b/packages/fcl-core/src/exec/mutate.js deleted file mode 100644 index c2744eef4..000000000 --- a/packages/fcl-core/src/exec/mutate.js +++ /dev/null @@ -1,100 +0,0 @@ -import * as sdk from "@onflow/sdk" -import {normalizeArgs} from "./utils/normalize-args" -import {getCurrentUser} from "../current-user" -import {prepTemplateOpts} from "./utils/prep-template-opts.js" -import {preMutate} from "./utils/pre.js" -import {isNumber} from "../utils/is" - -/** - * @description - * Factory function that returns a mutate function for a given currentUser. - * - * @param {ReturnType | import("../current-user").CurrentUserConfig} currentUserOrConfig - CurrentUser actor or configuration - */ -export const getMutate = currentUserOrConfig => { - /** - * @description - * Allows you to submit transactions to the blockchain to potentially mutate the state. - * - * @param {object} [opts] - Mutation Options and configuration - * @param {string} [opts.cadence] - Cadence Transaction used to mutate Flow - * @param {import("./args").ArgsFn} [opts.args] - Arguments passed to cadence transaction - * @param {object | string} [opts.template] - Interaction Template for a transaction - * @param {number} [opts.limit] - Compute Limit for transaction - * @param {Function} [opts.authz] - Authorization function for transaction - * @param {Function} [opts.proposer] - Proposer Authorization function for transaction - * @param {Function} [opts.payer] - Payer Authorization function for transaction - * @param {Array} [opts.authorizations] - Authorizations function for transaction - * @returns {Promise} Transaction Id - * - * @example - * fcl.mutate({ - * cadence: ` - * transaction(a: Int, b: Int, c: Address) { - * prepare(acct: AuthAccount) { - * log(acct) - * log(a) - * log(b) - * log(c) - * } - * } - * `, - * args: (arg, t) => [ - * arg(6, t.Int), - * arg(7, t.Int), - * arg("0xba1132bc08f82fe2", t.Address), - * ], - * }) - * - * - * Options: - * type Options = { - * template: InteractionTemplate | String // InteractionTemplate or url to one - * cadence: String!, - * args: (arg, t) => Array, - * limit: Number, - * authz: AuthzFn, // will overload the trinity of signatory roles - * proposer: AuthzFn, // will overload the proposer signatory role - * payer: AuthzFn, // will overload the payer signatory role - * authorizations: [AuthzFn], // an array of authorization functions used as authorizations signatory roles - * } - */ - const mutate = async (opts = {}) => { - var txid - try { - await preMutate(opts) - opts = await prepTemplateOpts(opts) - // Allow for a config to overwrite the authorization function. - // prettier-ignore - const currentUser = typeof currentUserOrConfig === "function" ? currentUserOrConfig : getCurrentUser(currentUserOrConfig) - const authz = await sdk - .config() - .get("fcl.authz", currentUser().authorization) - - txid = sdk - .send([ - sdk.transaction(opts.cadence), - - sdk.args(normalizeArgs(opts.args || [])), - - opts.limit && isNumber(opts.limit) && sdk.limit(opts.limit), - - // opts.proposer > opts.authz > authz - sdk.proposer(opts.proposer || opts.authz || authz), - - // opts.payer > opts.authz > authz - sdk.payer(opts.payer || opts.authz || authz), - - // opts.authorizations > [opts.authz > authz] - sdk.authorizations(opts.authorizations || [opts.authz || authz]), - ]) - .then(sdk.decode) - - return txid - } catch (error) { - throw error - } - } - - return mutate -} diff --git a/packages/fcl-core/src/exec/mutate.ts b/packages/fcl-core/src/exec/mutate.ts new file mode 100644 index 000000000..58b451305 --- /dev/null +++ b/packages/fcl-core/src/exec/mutate.ts @@ -0,0 +1,99 @@ +import type {AccountAuthorization} from "@onflow/sdk" +import * as sdk from "@onflow/sdk" +import {CurrentUserService, getCurrentUser} from "../current-user" +import {isNumber} from "../utils/is" +import type {ArgsFn} from "./args" +import {normalizeArgs} from "./utils/normalize-args" +import {preMutate} from "./utils/pre" +import {prepTemplateOpts} from "./utils/prep-template-opts" + +export interface MutateOptions { + cadence?: string + args?: ArgsFn + template?: any + limit?: number + authz?: AccountAuthorization + proposer?: AccountAuthorization + payer?: AccountAuthorization + authorizations?: AccountAuthorization[] +} + +/** + * @description Factory function that returns a mutate function for a given currentUser. + * + * @param currentUserOrConfig CurrentUser actor or configuration + */ +export const getMutate = (currentUserOrConfig: CurrentUserService) => { + /** + * @description Allows you to submit transactions to the blockchain to potentially mutate the state. + * + * When being used in the browser, `fcl.mutate` uses the built-in `fcl.authz` function to produce the authorization (signatures) for the current user. When calling this method from Node.js, you will need to supply your own custom authorization function. + * + * @param opts Mutation options configuration + * @param opts.cadence A valid cadence transaction (required) + * @param opts.args Any arguments to the script if needed should be supplied via a function that returns an array of arguments + * @param opts.limit Compute (Gas) limit for query. + * @param opts.proposer The authorization function that returns a valid AuthorizationObject for the proposer role + * @param opts.template Interaction Template for a transaction + * @param opts.authz Authorization function for transaction + * @param opts.payer Payer Authorization function for transaction + * @param opts.authorizations Authorizations function for transaction + * @returns The transaction ID + * + * @example + * import * as fcl from '@onflow/fcl'; + * // login somewhere before + * fcl.authenticate(); + * + * const txId = await fcl.mutate({ + * cadence: ` + * import Profile from 0xba1132bc08f82fe2 + * + * transaction(name: String) { + * prepare(account: auth(BorrowValue) &Account) { + * account.storage.borrow<&{Profile.Owner}>(from: Profile.privatePath)!.setName(name) + * } + * } + * `, + * args: (arg, t) => [arg('myName', t.String)], + * }); + */ + const mutate = async (opts: MutateOptions = {}): Promise => { + var txid + try { + await preMutate(opts) + opts = await prepTemplateOpts(opts) + // Allow for a config to overwrite the authorization function. + // prettier-ignore + const currentUser = typeof currentUserOrConfig === "function" ? currentUserOrConfig : getCurrentUser(currentUserOrConfig) + const authz: any = await sdk + .config() + .get("fcl.authz", currentUser().authorization) + + txid = sdk + .send([ + sdk.transaction(opts.cadence!), + + sdk.args(normalizeArgs(opts.args || [])), + + opts.limit && isNumber(opts.limit) && (sdk.limit(opts.limit!) as any), + + // opts.proposer > opts.authz > authz + sdk.proposer(opts.proposer || opts.authz || authz), + + // opts.payer > opts.authz > authz + sdk.payer(opts.payer || opts.authz || authz), + + // opts.authorizations > [opts.authz > authz] + sdk.authorizations(opts.authorizations || [opts.authz || authz]), + ]) + .then(sdk.decode) + + return txid + } catch (error) { + throw error + } + } + + return mutate +} diff --git a/packages/fcl-core/src/exec/query-raw.ts b/packages/fcl-core/src/exec/query-raw.ts index c4c1c2c0e..0f53dc43d 100644 --- a/packages/fcl-core/src/exec/query-raw.ts +++ b/packages/fcl-core/src/exec/query-raw.ts @@ -24,21 +24,21 @@ export interface QueryOptions { * @returns A promise that resolves to the raw query result * * @example - * const cadence = ` - * cadence: ` - * access(all) fun main(a: Int, b: Int, c: Address): Int { - * log(c) - * return a + b - * } - * `.trim() + * import * as fcl from '@onflow/fcl'; * - * const args = (arg, t) => [ - * arg(5, t.Int), - * arg(7, t.Int), - * arg("0xb2db43ad6bc345fec9", t.Address), - * ] - * - * await queryRaw({ cadence, args }) + * const result = await fcl.queryRaw({ + * cadence: ` + * access(all) fun main(a: Int, b: Int, addr: Address): Int { + * log(addr) + * return a + b + * } + * `, + * args: (arg, t) => [ + * arg(7, t.Int), // a: Int + * arg(6, t.Int), // b: Int + * arg('0xba1132bc08f82fe2', t.Address), // addr: Address + * ], + * }); */ export async function queryRaw(opts: QueryOptions = {}): Promise { await preQuery(opts) diff --git a/packages/fcl-core/src/exec/query.ts b/packages/fcl-core/src/exec/query.ts index 1af3f31b8..9df84d2b7 100644 --- a/packages/fcl-core/src/exec/query.ts +++ b/packages/fcl-core/src/exec/query.ts @@ -4,30 +4,31 @@ import {QueryOptions, queryRaw} from "./query-raw" /** * @description Allows you to submit scripts to query the blockchain. * - * @param opts Query Options and configuration - * @param opts.cadence Cadence Script used to query Flow - * @param opts.args Arguments passed to cadence script + * @param opts Query options configuration + * @param opts.cadence A valid cadence script (required) + * @param opts.args Any arguments to the script if needed should be supplied via a function that returns an array of arguments + * @param opts.limit Compute (Gas) limit for query. * @param opts.template Interaction Template for a script * @param opts.isSealed Block Finality - * @param opts.limit Compute Limit for Query - * @returns A promise that resolves to the query result + * @returns A JSON representation of the response * * @example - * const cadence = ` - * cadence: ` - * access(all) fun main(a: Int, b: Int, c: Address): Int { - * log(c) - * return a + b - * } - * `.trim() + * import * as fcl from '@onflow/fcl'; * - * const args = (arg, t) => [ - * arg(5, t.Int), - * arg(7, t.Int), - * arg("0xb2db43ad6bc345fec9", t.Address), - * ] - * - * await query({ cadence, args }) + * const result = await fcl.query({ + * cadence: ` + * access(all) fun main(a: Int, b: Int, addr: Address): Int { + * log(addr) + * return a + b + * } + * `, + * args: (arg, t) => [ + * arg(7, t.Int), // a: Int + * arg(6, t.Int), // b: Int + * arg('0xba1132bc08f82fe2', t.Address), // addr: Address + * ], + * }); + * console.log(result); // 13 */ export async function query(opts: QueryOptions = {}): Promise { return queryRaw(opts).then(sdk.decode) diff --git a/packages/fcl-core/src/exec/utils/normalize-args.js b/packages/fcl-core/src/exec/utils/normalize-args.js deleted file mode 100644 index b0cc38589..000000000 --- a/packages/fcl-core/src/exec/utils/normalize-args.js +++ /dev/null @@ -1,8 +0,0 @@ -import {isFunc} from "../../utils/is" -import * as sdk from "@onflow/sdk" -import * as t from "@onflow/types" - -export function normalizeArgs(ax) { - if (isFunc(ax)) return ax(sdk.arg, t) - return [] -} diff --git a/packages/fcl-core/src/exec/utils/normalize-args.ts b/packages/fcl-core/src/exec/utils/normalize-args.ts new file mode 100644 index 000000000..119d064dd --- /dev/null +++ b/packages/fcl-core/src/exec/utils/normalize-args.ts @@ -0,0 +1,26 @@ +import {isFunc} from "../../utils/is" +import * as sdk from "@onflow/sdk" +import * as t from "@onflow/types" +import type {ArgsFn} from "../args" + +/** + * @description Normalizes function or array arguments into a standard array format for use with + * Flow transactions and scripts. If the input is a function, it executes the function with + * sdk.arg and types as parameters. Otherwise, returns an empty array. + * + * @param ax Arguments function, array, or undefined value to normalize + * @returns Normalized array of arguments ready for use with Flow transactions/scripts + * + * @example + * // Using with function-style arguments + * const argsFn = (arg, t) => [ + * arg("Hello", t.String), + * arg(42, t.Int) + * ] + * const normalized = normalizeArgs(argsFn) + * // Returns: [{value: "Hello", xform: ...}, {value: 42, xform: ...}] + */ +export function normalizeArgs(ax: ArgsFn | any[] | undefined): any[] { + if (isFunc(ax)) return (ax as ArgsFn)(sdk.arg, t) + return [] +} diff --git a/packages/fcl-core/src/exec/utils/pre.js b/packages/fcl-core/src/exec/utils/pre.js deleted file mode 100644 index a92962836..000000000 --- a/packages/fcl-core/src/exec/utils/pre.js +++ /dev/null @@ -1,32 +0,0 @@ -import {invariant} from "@onflow/util-invariant" -import * as sdk from "@onflow/sdk" -import {isRequired, isObject, isString} from "../../utils/is" - -async function pre(type, opts) { - // prettier-ignore - invariant(isRequired(opts), `${type}(opts) -- opts is required`) - // prettier-ignore - invariant(isObject(opts), `${type}(opts) -- opts must be an object`) - // prettier-ignore - invariant(!(opts.cadence && opts.template), `${type}({ template, cadence }) -- cannot pass both cadence and template`) - // prettier-ignore - invariant(isRequired(opts.cadence || opts?.template), `${type}({ cadence }) -- cadence is required`) - // // prettier-ignore - invariant( - isString(opts.cadence) || opts?.template, - `${type}({ cadence }) -- cadence must be a string` - ) - // prettier-ignore - invariant( - await sdk.config().get("accessNode.api"), - `${type}(opts) -- Required value for "accessNode.api" not defined in config. See: ${"https://github.com/onflow/flow-js-sdk/blob/master/packages/fcl/src/exec/query.md#configuration"}` - ) -} - -export async function preMutate(opts) { - return pre("mutate", opts) -} - -export async function preQuery(opts) { - return pre("query", opts) -} diff --git a/packages/fcl-core/src/exec/utils/pre.ts b/packages/fcl-core/src/exec/utils/pre.ts new file mode 100644 index 000000000..392c498eb --- /dev/null +++ b/packages/fcl-core/src/exec/utils/pre.ts @@ -0,0 +1,81 @@ +import {invariant} from "@onflow/util-invariant" +import * as sdk from "@onflow/sdk" +import {isRequired, isObject, isString} from "../../utils/is" + +export interface PreOptions { + cadence?: string + template?: any +} + +async function pre(type: string, opts: PreOptions): Promise { + // prettier-ignore + invariant(isRequired(opts), `${type}(opts) -- opts is required`) + // prettier-ignore + invariant(isObject(opts), `${type}(opts) -- opts must be an object`) + // prettier-ignore + invariant(!(opts.cadence && opts.template), `${type}({ template, cadence }) -- cannot pass both cadence and template`) + // prettier-ignore + invariant(isRequired(opts.cadence || opts?.template), `${type}({ cadence }) -- cadence is required`) + // // prettier-ignore + invariant( + isString(opts.cadence) || opts?.template, + `${type}({ cadence }) -- cadence must be a string` + ) + // prettier-ignore + invariant( + await sdk.config().get("accessNode.api"), + `${type}(opts) -- Required value for "accessNode.api" not defined in config. See: ${"https://github.com/onflow/flow-js-sdk/blob/master/packages/fcl/src/exec/query.md#configuration"}` + ) +} + +/** + * @description Validates and prepares options for Flow transaction execution (mutations). This function + * performs comprehensive validation of the provided options to ensure they meet the requirements for + * executing transactions on the Flow blockchain, including checking for required configuration values. + * + * @param opts Options object containing either Cadence code or template references for the transaction + * @param opts.cadence Optional Cadence transaction code string + * @param opts.template Optional interaction template object or reference + * @returns Promise that resolves when validation passes + * @throws Error if validation fails or required configuration is missing + * + * @example + * // Validate transaction options with Cadence code + * await preMutate({ + * cadence: "transaction { execute { log(\"Hello Flow!\") } }" + * }) + * + * // Validate transaction options with template + * await preMutate({ + * template: transferFlowTemplate + * }) + */ +export async function preMutate(opts: PreOptions): Promise { + return pre("mutate", opts) +} + +/** + * @description Validates and prepares options for Flow script execution (queries). This function + * performs comprehensive validation of the provided options to ensure they meet the requirements for + * executing scripts on the Flow blockchain, including checking for required configuration values. + * + * @param opts Options object containing either Cadence code or template references for the script + * @param opts.cadence Optional Cadence script code string + * @param opts.template Optional interaction template object or reference + * @returns Promise that resolves when validation passes + * @throws Error if validation fails or required configuration is missing + * + * @example + * // Validate script options with Cadence code + * await preQuery({ + * cadence: "access(all) fun main(): String { return \"Hello Flow!\" }" + * }) + * + * // Validate script options with template + * await preQuery({ + * template: getAccountTemplate + * }) + */ +export async function preQuery(opts: PreOptions): Promise { + return pre("query", opts) +} diff --git a/packages/fcl-core/src/exec/utils/prep-template-opts.js b/packages/fcl-core/src/exec/utils/prep-template-opts.js deleted file mode 100644 index 2a8f30f84..000000000 --- a/packages/fcl-core/src/exec/utils/prep-template-opts.js +++ /dev/null @@ -1,21 +0,0 @@ -import {retrieve} from "../../document/document.js" -import {deriveCadenceByNetwork} from "../../interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.js" -import {isString} from "../../utils/is" -import {getChainId} from "../../utils" - -export async function prepTemplateOpts(opts) { - if (isString(opts?.template)) { - opts.template = await retrieve({url: opts?.template}) - } - - const cadence = - opts.cadence || - (await deriveCadenceByNetwork({ - template: opts.template, - network: await getChainId(opts), - })) - - opts.cadence = cadence - - return opts -} diff --git a/packages/fcl-core/src/exec/utils/prep-template-opts.test.js b/packages/fcl-core/src/exec/utils/prep-template-opts.test.ts similarity index 98% rename from packages/fcl-core/src/exec/utils/prep-template-opts.test.js rename to packages/fcl-core/src/exec/utils/prep-template-opts.test.ts index d6d2218a8..8520c514e 100644 --- a/packages/fcl-core/src/exec/utils/prep-template-opts.test.js +++ b/packages/fcl-core/src/exec/utils/prep-template-opts.test.ts @@ -1,5 +1,5 @@ import {config} from "@onflow/config" -import {prepTemplateOpts} from "./prep-template-opts.js" +import {prepTemplateOpts} from "./prep-template-opts" describe("Prepare template options for template version 1.0.0", () => { // NOTE: template10 and template11 copied from packages\fcl-core\src\interaction-template-utils\derive-cadence-by-network\derive-cadence-by-network.test.js @@ -175,7 +175,7 @@ describe("Prepare template options for template version 1.1.0", () => { const test = async () => await prepTemplateOpts({ - template: template, + template: template11, }) await expect(test()).rejects.toThrow(Error) diff --git a/packages/fcl-core/src/exec/utils/prep-template-opts.ts b/packages/fcl-core/src/exec/utils/prep-template-opts.ts new file mode 100644 index 000000000..2256d9c6f --- /dev/null +++ b/packages/fcl-core/src/exec/utils/prep-template-opts.ts @@ -0,0 +1,59 @@ +import {retrieve} from "../../document/document" +import {deriveCadenceByNetwork} from "../../interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network" +import {isString} from "../../utils/is" +import {getChainId} from "../../utils" + +export interface TemplateOptions { + cadence?: string + template?: any +} + +/** + * @description Prepares and processes template options for Flow transactions and scripts. This function handles + * the resolution of interaction templates by either fetching them from a URL or using provided template data, + * and derives the appropriate Cadence code based on the current network configuration. + * + * @param opts Template options object that can contain either direct Cadence code or template references + * @param opts.cadence Optional Cadence code string to use directly + * @param opts.template Optional template object or URL string. If a URL string is provided, the template will be fetched + * @returns Promise that resolves to the processed template options with resolved Cadence code + * + * @example + * // Prepare template with direct Cadence code + * const opts = await prepTemplateOpts({ + * cadence: "transaction { execute { log(\"Hello Flow!\") } }" + * }) + * + * // Prepare template from URL + * const opts = await prepTemplateOpts({ + * template: "https://flix.flow.com/v1/templates/transfer-flow" + * }) + * + * // Prepare template with template object + * const opts = await prepTemplateOpts({ + * template: { + * f_type: "InteractionTemplate", + * f_version: "1.1.0", + * id: "transfer-flow", + * data: { cadence: { "flow-mainnet": "transaction { ... }" } } + * } + * }) + */ +export async function prepTemplateOpts( + opts: TemplateOptions +): Promise { + if (isString(opts?.template)) { + opts.template = await retrieve({url: opts?.template}) + } + + const cadence = + opts.cadence || + (await deriveCadenceByNetwork({ + template: opts.template, + network: await getChainId(opts), + })) + + opts.cadence = cadence + + return opts +} diff --git a/packages/fcl-core/src/exec/verify.js b/packages/fcl-core/src/exec/verify.ts similarity index 74% rename from packages/fcl-core/src/exec/verify.js rename to packages/fcl-core/src/exec/verify.ts index 7c64450af..7f0484d40 100644 --- a/packages/fcl-core/src/exec/verify.js +++ b/packages/fcl-core/src/exec/verify.ts @@ -2,16 +2,14 @@ import {log} from "@onflow/util-logger" import {verifyUserSignatures as verify} from "../app-utils" /** - * Verify a valid signature/s for an account on Flow. - * + * @description Verify a valid signature/s for an account on Flow. * @deprecated since version '1.0.0-alpha.0', use AppUtils.verifyUserSignatures instead - * */ export const verifyUserSignatures = log.deprecate({ pkg: "FCL", subject: "fcl.verifyUserSignatures()", message: "Please use fcl.AppUtils.verifyUserSignatures()", - callback: function verifyUserSignatures(message, compSigs) { + callback: function verifyUserSignatures(message: any, compSigs: any) { return verify(message, compSigs) }, }) diff --git a/packages/fcl-core/src/fcl-core.ts b/packages/fcl-core/src/fcl-core.ts index 6ea39eb32..b0d930fd5 100644 --- a/packages/fcl-core/src/fcl-core.ts +++ b/packages/fcl-core/src/fcl-core.ts @@ -12,6 +12,7 @@ export {discovery} import * as types from "@onflow/types" export {types as t} +export * from "@onflow/typedefs" import * as WalletUtils from "./wallet-utils" export {WalletUtils} @@ -83,6 +84,8 @@ watchForChainIdChanges() export {getMutate} from "./exec/mutate" export {getCurrentUser} from "./current-user" +import type {CurrentUserConfig, CurrentUserService} from "./current-user" +export type {CurrentUserConfig, CurrentUserService} export {initServiceRegistry} from "./current-user/exec-service/plugins" @@ -103,3 +106,8 @@ export { export {execStrategy} from "./current-user/exec-service" export type {StorageProvider} from "./utils/storage" + +export type { + AccountProofData, + VerifySignaturesScriptOptions, +} from "./app-utils/verify-signatures" diff --git a/packages/fcl-core/src/fcl.test.js b/packages/fcl-core/src/fcl.test.ts similarity index 93% rename from packages/fcl-core/src/fcl.test.js rename to packages/fcl-core/src/fcl.test.ts index a7f2843e8..23fb633b5 100644 --- a/packages/fcl-core/src/fcl.test.js +++ b/packages/fcl-core/src/fcl.test.ts @@ -25,7 +25,7 @@ test("serialize returns voucher", async () => { limit(156), proposer(authz), authorizations([authz]), - payer(authz), + payer(authz as any), ref("123"), ]) ) @@ -38,7 +38,7 @@ test("serialize returns voucher", async () => { limit(156), proposer(authz), authorizations([authz]), - payer(authz), + payer(authz as any), ref("123"), ], {resolve} diff --git a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.0.0.js b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.0.0.js deleted file mode 100644 index bfa2814d7..000000000 --- a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.0.0.js +++ /dev/null @@ -1,49 +0,0 @@ -import {invariant} from "@onflow/util-invariant" - -/** - * @description Fills import addresses in Cadence for network - * - * @param {object} params - * @param {string} params.network - Network to derive Cadence for - * @param {object} params.template - Interaction Template to derive Cadence from - * @returns {Promise} - Promise that resolves with the derived Cadence code - */ -export async function deriveCadenceByNetwork100({network, template}) { - invariant( - template.f_version === "1.0.0", - "deriveCadenceByNetwork100({ template }) -- template must be version 1.0.0" - ) - - const networkDependencies = Object.keys(template?.data?.dependencies).map( - dependencyPlaceholder => { - const dependencyNetworkContracts = Object.values( - template?.data?.dependencies?.[dependencyPlaceholder] - ) - - invariant( - dependencyNetworkContracts !== undefined, - `deriveCadenceByNetwork100 -- Could not find contracts for dependency placeholder: ${dependencyPlaceholder}` - ) - - invariant( - dependencyNetworkContracts.length > 0, - `deriveCadenceByNetwork100 -- Could not find contracts for dependency placeholder: ${dependencyPlaceholder}` - ) - - const dependencyContract = dependencyNetworkContracts[0] - const dependencyContractForNetwork = dependencyContract?.[network] - - invariant( - dependencyContractForNetwork, - `deriveCadenceByNetwork100 -- Could not find ${network} network information for dependency: ${dependencyPlaceholder}` - ) - - return [dependencyPlaceholder, dependencyContractForNetwork?.address] - } - ) - - return networkDependencies.reduce((cadence, [placeholder, address]) => { - const regex = new RegExp("(\\b" + placeholder + "\\b)", "g") - return cadence.replace(regex, address) - }, template.data.cadence) -} diff --git a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.0.0.ts b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.0.0.ts new file mode 100644 index 000000000..04b40fbd8 --- /dev/null +++ b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.0.0.ts @@ -0,0 +1,61 @@ +import {invariant} from "@onflow/util-invariant" +import type {InteractionTemplate100} from "../interaction-template" + +export interface DeriveCadenceByNetwork100Params { + network: string + template: InteractionTemplate100 +} + +/** + * @description Fills import addresses in Cadence for network + * + * @param params + * @param params.network Network to derive Cadence for + * @param params.template Interaction Template to derive Cadence from + * @returns Promise that resolves with the derived Cadence code + */ +export async function deriveCadenceByNetwork100({ + network, + template, +}: DeriveCadenceByNetwork100Params): Promise { + invariant( + template.f_version === "1.0.0", + "deriveCadenceByNetwork100({ template }) -- template must be version 1.0.0" + ) + + const networkDependencies: [string, string][] = Object.keys( + template?.data?.dependencies + ).map((dependencyPlaceholder: string): [string, string] => { + const dependencyNetworkContracts = Object.values( + template?.data?.dependencies?.[dependencyPlaceholder] + ) + + invariant( + dependencyNetworkContracts !== undefined, + `deriveCadenceByNetwork100 -- Could not find contracts for dependency placeholder: ${dependencyPlaceholder}` + ) + + invariant( + dependencyNetworkContracts.length > 0, + `deriveCadenceByNetwork100 -- Could not find contracts for dependency placeholder: ${dependencyPlaceholder}` + ) + + const dependencyContract = dependencyNetworkContracts[0] + const dependencyContractForNetwork = dependencyContract?.[network] + + invariant( + dependencyContractForNetwork as any, + `deriveCadenceByNetwork100 -- Could not find ${network} network information for dependency: ${dependencyPlaceholder}` + ) + + return [dependencyPlaceholder, dependencyContractForNetwork?.address] + }) + + return networkDependencies.reduce( + (cadence: string, [placeholder, address]: [string, string]) => { + const regex = new RegExp("(\\b" + placeholder + "\\b)", "g") + return cadence.replace(regex, address) + }, + template.data.cadence + ) +} diff --git a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.1.0.js b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.1.0.ts similarity index 71% rename from packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.1.0.js rename to packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.1.0.ts index e2aa725be..df967d8d2 100644 --- a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.1.0.js +++ b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network-1.1.0.ts @@ -1,21 +1,31 @@ import {invariant} from "@onflow/util-invariant" -import {replaceStringImports} from "../utils/replace-string-imports.js" +import {replaceStringImports} from "../utils/replace-string-imports" +import type {InteractionTemplate110} from "../interaction-template" + +export interface DeriveCadenceByNetwork110Params { + network: string + template: InteractionTemplate110 +} + /** * @description Fills import addresses in Cadence for network * - * @param {object} params - * @param {string} params.network - Network to derive Cadence for - * @param {object} params.template - Interaction Template to derive Cadence from - * @returns {Promise} - Promise that resolves with the derived Cadence code + * @param params + * @param params.network Network to derive Cadence for + * @param params.template Interaction Template to derive Cadence from + * @returns Promise that resolves with the derived Cadence code */ -export async function deriveCadenceByNetwork110({network, template}) { +export async function deriveCadenceByNetwork110({ + network, + template, +}: DeriveCadenceByNetwork110Params): Promise { invariant( template.f_version === "1.1.0", - "deriveCadenceByNetwork110({ template }) -- template must be version 1.0.0" + "deriveCadenceByNetwork110({ template }) -- template must be version 1.1.0" ) // get network dependencies from template dependencies, use new string import format - const networkDependencies = {} + const networkDependencies: Record = {} template?.data?.dependencies.forEach(dependency => { dependency.contracts.forEach(contract => { @@ -46,7 +56,7 @@ export async function deriveCadenceByNetwork110({network, template}) { ) invariant( - template?.data?.cadence?.body, + !!template?.data?.cadence?.body, `no cadence found -- Could not replace import dependencies: ${networkDependencies}` ) diff --git a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.test.js b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.test.ts similarity index 99% rename from packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.test.js rename to packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.test.ts index 4124bce4b..152669c35 100644 --- a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.test.js +++ b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.test.ts @@ -1,7 +1,7 @@ -import {deriveCadenceByNetwork} from "./derive-cadence-by-network.js" +import {deriveCadenceByNetwork} from "./derive-cadence-by-network" describe("Derive cadence by network 1.0.0", () => { - const template = { + const template: any = { f_type: "InteractionTemplate", f_version: "1.0.0", id: "abc123", @@ -54,7 +54,7 @@ describe("Derive cadence by network 1.0.0", () => { }) describe("Derive cadence by network 1.1.0", () => { - const template11 = { + const template11: any = { f_type: "InteractionTemplate", f_version: "1.1.0", id: "3a99af243b85f3f6af28304af2ed53a37fb913782b3efc483e6f0162a47720a0", @@ -207,7 +207,7 @@ describe("Derive cadence by network 1.1.0", () => { await expect(() => deriveCadenceByNetwork({ network: "mainnet", - template: {f_type: "InteractionTemplate", f_version: "0.0.0"}, + template: {f_type: "InteractionTemplate", f_version: "0.0.0"} as any, }) ).rejects.toThrow(Error) }) diff --git a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.js b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.ts similarity index 69% rename from packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.js rename to packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.ts index 0e565437f..9758b5f19 100644 --- a/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.js +++ b/packages/fcl-core/src/interaction-template-utils/derive-cadence-by-network/derive-cadence-by-network.ts @@ -1,16 +1,25 @@ import {invariant} from "@onflow/util-invariant" -import {deriveCadenceByNetwork100} from "./derive-cadence-by-network-1.0.0.js" -import {deriveCadenceByNetwork110} from "./derive-cadence-by-network-1.1.0.js" +import {deriveCadenceByNetwork100} from "./derive-cadence-by-network-1.0.0" +import {deriveCadenceByNetwork110} from "./derive-cadence-by-network-1.1.0" +import type {InteractionTemplate} from "../interaction-template" + +export interface DeriveCadenceByNetworkParams { + network: string + template: InteractionTemplate +} /** * @description Fills import addresses in Cadence for network * - * @param {object} params - * @param {string} params.network - Network to derive Cadence for - * @param {object} params.template - Interaction Template to derive Cadence from - * @returns {Promise} - Promise that resolves with the derived Cadence code + * @param params + * @param params.network Network to derive Cadence for + * @param params.template Interaction Template to derive Cadence from + * @returns Promise that resolves with the derived Cadence code */ -export async function deriveCadenceByNetwork({network, template}) { +export async function deriveCadenceByNetwork({ + network, + template, +}: DeriveCadenceByNetworkParams): Promise { invariant( network != undefined, "deriveCadenceByNetwork({ network }) -- network must be defined" diff --git a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.0.0.js b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.0.0.ts similarity index 66% rename from packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.0.0.js rename to packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.0.0.ts index 28958346e..c7a9500a3 100644 --- a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.0.0.js +++ b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.0.0.ts @@ -1,20 +1,25 @@ import {invariant, send, getAccount, config, decode} from "@onflow/sdk" -import {genHash} from "../utils/hash.js" -import {findImports} from "../utils/find-imports.js" -import {generateImport} from "../utils/generate-import.js" +import {genHash} from "../utils/hash" +import {findImports} from "../utils/find-imports" +import {generateImport} from "../utils/generate-import" + +export interface GenerateDependencyPin100Params { + address: string + contractName: string +} /** * @description Produces a dependency pin for a contract at current state of chain - * @param {object} params - * @param {string} params.address - The address of the account containing the contract - * @param {string} params.contractName - The name of the contract - * @param {object} opts - Options to pass to the interaction - * @returns {Promise} - The dependency pin + * @param params + * @param params.address The address of the account containing the contract + * @param params.contractName The name of the contract + * @param opts Options to pass to the interaction + * @returns The dependency pin */ export async function generateDependencyPin100( - {address, contractName}, - opts = {} -) { + {address, contractName}: GenerateDependencyPin100Params, + opts: any = {} +): Promise { invariant( address != undefined, "generateDependencyPin({ address }) -- address must be defined" @@ -32,7 +37,7 @@ export async function generateDependencyPin100( "generateDependencyPin({ contractName }) -- contractName must be a string" ) - const horizon = [generateImport({contractName, address})] + const horizon: any = [generateImport({contractName, address})] for (const horizonImport of horizon) { const account = await send( @@ -56,7 +61,7 @@ export async function generateDependencyPin100( horizon.push(...contractImports) } - const contractHashes = horizon.map(iport => genHash(iport.contract)) + const contractHashes = horizon.map((iport: any) => genHash(iport.contract)) const contractHashesJoined = contractHashes.join("") diff --git a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.1.0.js b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.1.0.ts similarity index 66% rename from packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.1.0.js rename to packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.1.0.ts index df7fd154b..22521cee4 100644 --- a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.1.0.js +++ b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin-1.1.0.ts @@ -1,20 +1,27 @@ import {invariant, send, getAccount, config, decode} from "@onflow/sdk" -import {genHash} from "../utils/hash.js" -import {findImports} from "../utils/find-imports.js" -import {generateImport} from "../utils/generate-import.js" +import {genHash} from "../utils/hash" +import {findImports} from "../utils/find-imports" +import {generateImport} from "../utils/generate-import" + +export interface GenerateDependencyPin110Params { + address: string + contractName: string + blockHeight?: number +} /** * @description Produces a dependency pin for a contract at current state of chain - * @param {object} params - * @param {string} params.address - The address of the account containing the contract - * @param {string} params.contractName - The name of the contract - * @param {object} opts - Options to pass to the interaction - * @returns {Promise} - The dependency pin + * @param params + * @param params.address The address of the account containing the contract + * @param params.contractName The name of the contract + * @param params.blockHeight The block height to generate the dependency pin at + * @param opts Options to pass to the interaction + * @returns The dependency pin */ export async function generateDependencyPin110( - {address, contractName}, - opts = {} -) { + {address, contractName}: GenerateDependencyPin110Params, + opts: any = {} +): Promise { invariant( address != undefined, "generateDependencyPin({ address }) -- address must be defined" @@ -32,7 +39,7 @@ export async function generateDependencyPin110( "generateDependencyPin({ contractName }) -- contractName must be a string" ) - const horizon = [generateImport({contractName, address})] + const horizon: any = [generateImport({contractName, address})] for (const horizonImport of horizon) { const account = await send( @@ -56,7 +63,7 @@ export async function generateDependencyPin110( horizon.push(...contractImports) } - const contractPinSelfHashesPromises = horizon.map(iport => + const contractPinSelfHashesPromises = horizon.map((iport: any) => genHash(iport.contract) ) // genHash returns a promise, so we need to await the results of all the promises diff --git a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.js b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.js deleted file mode 100644 index c18f95567..000000000 --- a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.js +++ /dev/null @@ -1,67 +0,0 @@ -import {block, invariant} from "@onflow/sdk" -import {generateDependencyPin110} from "./generate-dependency-pin-1.1.0.js" -import {generateDependencyPin100} from "./generate-dependency-pin-1.0.0.js" - -/** - * @description Produces a dependency pin for a contract at current state of chain - * @param {object} params - * @param {string} params.version - The version of the interaction template - * @param {string} params.address - The address of the account containing the contract - * @param {string} params.contractName - The name of the contract - * @param {object} opts - Options to pass to the interaction - * @returns {Promise} - The dependency pin - */ -export async function generateDependencyPin( - {version, address, contractName}, - opts = {} -) { - invariant( - address != undefined, - "generateDependencyPin({ address }) -- address must be defined" - ) - invariant( - contractName != undefined, - "generateDependencyPin({ contractName }) -- contractName must be defined" - ) - invariant( - typeof address === "string", - "generateDependencyPin({ address }) -- address must be a string" - ) - invariant( - typeof contractName === "string", - "generateDependencyPin({ contractName }) -- contractName must be a string" - ) - - switch (version) { - case "1.1.0": - return await generateDependencyPin110({address, contractName}) - case "1.0.0": - return await generateDependencyPin100({address, contractName}) - default: - throw new Error( - "deriveCadenceByNetwork Error: Unsupported template version" - ) - } -} - -/** - * @description Produces a dependency pin for a contract at latest sealed block - * @param {object} params - * @param {string} params.version - The version of the interaction template - * @param {string} params.address - The address of the account containing the contract - * @param {string} params.contractName - The name of the contract - * @param {object} opts - Options to pass to the interaction - * @returns {Promise} - The dependency pin - */ -export async function generateDependencyPinAtLatestSealedBlock( - {version, address, contractName}, - opts = {} -) { - const latestSealedBlock = await block({sealed: true}, opts) - const latestSealedBlockHeight = latestSealedBlock?.height - - return generateDependencyPin( - {version, address, contractName, blockHeight: latestSealedBlockHeight}, - opts - ) -} diff --git a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.test.js b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.test.ts similarity index 98% rename from packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.test.js rename to packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.test.ts index f7c2c2999..ada4fc5d3 100644 --- a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.test.js +++ b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.test.ts @@ -1,8 +1,5 @@ -import { - generateDependencyPin110, - generateDependencySelfPin, -} from "./generate-dependency-pin-1.1.0.js" import {config} from "@onflow/config" +import {generateDependencyPin110} from "./generate-dependency-pin-1.1.0" const returnedAccount = { address: "0xf233dcee88fe0abe", @@ -278,12 +275,14 @@ jest.mock("@onflow/sdk", () => ({ })) describe("1.1.0, generate dependency pin", () => { + let warnSpy: jest.SpyInstance + beforeAll(() => { - jest.spyOn(console, "warn").mockImplementation(() => {}) + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) }) afterAll(() => { - console.warn.mockRestore() + warnSpy.mockRestore() }) test("v1.1.0, get dependency pin", async () => { diff --git a/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.ts b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.ts new file mode 100644 index 000000000..e2f6a1ee3 --- /dev/null +++ b/packages/fcl-core/src/interaction-template-utils/generate-dependency-pin/generate-dependency-pin.ts @@ -0,0 +1,110 @@ +import {block, invariant} from "@onflow/sdk" +import {generateDependencyPin110} from "./generate-dependency-pin-1.1.0" +import {generateDependencyPin100} from "./generate-dependency-pin-1.0.0" + +export interface GenerateDependencyPinParams { + version?: string + address: string + contractName: string + blockHeight?: number +} + +/** + * @description Generates a dependency pin for a smart contract on the Flow blockchain. A dependency + * pin is a cryptographic hash that uniquely identifies a specific version of a contract at a particular + * state. This is used in Interaction Templates to ensure consistent behavior by pinning to specific + * contract versions and preventing issues from contract updates. + * + * @param params + * @param params.version The version of the interaction template (e.g., "1.0.0", "1.1.0") + * @param params.address The Flow account address containing the contract (with or without 0x prefix) + * @param params.contractName The name of the contract to generate a pin for + * @param params.blockHeight Optional specific block height to pin to + * @param opts Additional options to pass to the underlying interaction + * + * @returns Promise that resolves to the dependency pin as a string + * + * @throws If required parameters are missing or invalid, or if the template version is unsupported + * + * @example + * // Generate dependency pin for a contract at current state + * import * as fcl from "@onflow/fcl" + * + * const dependencyPin = await fcl.InteractionTemplateUtils.generateDependencyPin({ + * version: "1.1.0", + * address: "0x1654653399040a61", + * contractName: "FlowToken" + * }) + */ +export async function generateDependencyPin( + {version, address, contractName}: GenerateDependencyPinParams, + opts: any = {} +): Promise { + invariant( + address != undefined, + "generateDependencyPin({ address }) -- address must be defined" + ) + invariant( + contractName != undefined, + "generateDependencyPin({ contractName }) -- contractName must be defined" + ) + invariant( + typeof address === "string", + "generateDependencyPin({ address }) -- address must be a string" + ) + invariant( + typeof contractName === "string", + "generateDependencyPin({ contractName }) -- contractName must be a string" + ) + + switch (version) { + case "1.1.0": + return await generateDependencyPin110({address, contractName}) + case "1.0.0": + return await generateDependencyPin100({address, contractName}) + default: + throw new Error( + "deriveCadenceByNetwork Error: Unsupported template version" + ) + } +} + +/** + * @description Generates a dependency pin for a smart contract at the latest sealed block on the Flow + * blockchain. This variant ensures the pin is generated against the most recent finalized state of the + * network, providing consistency and avoiding issues with pending transactions affecting the pin generation. + * + * @param params + * @param params.version The version of the interaction template (e.g., "1.0.0", "1.1.0") + * @param params.address The Flow account address containing the contract (with or without 0x prefix) + * @param params.contractName The name of the contract to generate a pin for + * @param params.blockHeight This parameter is ignored as the function always uses latest sealed block + * @param opts Additional options to pass to the underlying interaction + * + * @returns Promise that resolves to the dependency pin as a string + * + * @throws If required parameters are missing or invalid, template version is unsupported, + * or if unable to fetch the latest sealed block + * + * @example + * // Generate dependency pin at latest sealed block + * import * as fcl from "@onflow/fcl" + * + * const dependencyPin = await fcl.InteractionTemplateUtils.generateDependencyPinAtLatestSealedBlock({ + * version: "1.1.0", + * address: "0x1654653399040a61", + * contractName: "FlowToken" + * }) + */ +export async function generateDependencyPinAtLatestSealedBlock( + {version, address, contractName}: GenerateDependencyPinParams, + opts: any = {} +): Promise { + const latestSealedBlock = await block({sealed: true}, opts) + const latestSealedBlockHeight = latestSealedBlock?.height + + return generateDependencyPin( + {version, address, contractName, blockHeight: latestSealedBlockHeight}, + opts + ) +} diff --git a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.0.0.js b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.0.0.ts similarity index 90% rename from packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.0.0.js rename to packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.0.0.ts index 1361c8bad..4c6ca9a0b 100644 --- a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.0.0.js +++ b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.0.0.ts @@ -1,17 +1,24 @@ -import {invariant} from "@onflow/sdk" +import {invariant} from "@onflow/util-invariant" import {encode as rlpEncode} from "@onflow/rlp" -import {genHash} from "../utils/hash.js" +import {genHash} from "../utils/hash" +import type {InteractionTemplate100} from "../interaction-template" + +export interface GenerateTemplateId100Params { + template: InteractionTemplate100 +} /** * @description Generates Interaction Template ID for a given Interaction Template * - * @param {object} params - * @param {object} params.template - Interaction Template - * @returns {Promise} - Interaction Template ID + * @param params + * @param params.template Interaction Template + * @returns Interaction Template ID */ -export async function generateTemplateId({template}) { +export async function generateTemplateId({ + template, +}: GenerateTemplateId100Params): Promise { invariant( - template != undefined, + !!template, "generateTemplateId({ template }) -- template must be defined" ) invariant( diff --git a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.test.js b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.test.ts similarity index 99% rename from packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.test.js rename to packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.test.ts index 073c99579..d5d87f118 100644 --- a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.test.js +++ b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.test.ts @@ -1,6 +1,6 @@ -import {generateTemplateId} from "./generate-template-id.js" -import {replaceStringImports} from "../utils/replace-string-imports.js" -import {genHash} from "../utils/hash.js" +import {generateTemplateId} from "./generate-template-id" +import {replaceStringImports} from "../utils/replace-string-imports" +import {genHash} from "../utils/hash" const returnedAccount = { address: "0xf233dcee88fe0abe", @@ -435,7 +435,7 @@ describe("Gen template id interaction template messages 1.1.0", () => { test("Test id generation and compare", async () => { const testId = template.id const id = await generateTemplateId({ - template, + template: template as any, }) expect(id).toEqual(testId) diff --git a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.js b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.ts similarity index 55% rename from packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.js rename to packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.ts index be8704e00..c543bf80d 100644 --- a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.js +++ b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id-1.1.0.ts @@ -1,10 +1,21 @@ -import {invariant} from "@onflow/util-invariant" import {encode as rlpEncode} from "@onflow/rlp" -import {genHash} from "../utils/hash.js" -import {generateDependencyPin110} from "../generate-dependency-pin/generate-dependency-pin-1.1.0.js" +import {invariant} from "@onflow/util-invariant" +import type { + InteractionTemplate110, + InteractionTemplateDependency, + InteractionTemplateI18n, + InteractionTemplateMessage, + InteractionTemplateNetwork, + InteractionTemplateParameter, +} from "../interaction-template" +import {generateDependencyPin110} from "../generate-dependency-pin/generate-dependency-pin-1.1.0" +import {genHash} from "../utils/hash" -async function generateContractNetworks(contractName, networks) { - const values = [] +async function generateContractNetworks( + contractName: string, + networks: InteractionTemplateNetwork[] +): Promise { + const values: string[][] = [] for (const net of networks) { const networkHashes = [genHash(net.network)] const {address, dependency_pin_block_height} = net @@ -21,8 +32,10 @@ async function generateContractNetworks(contractName, networks) { return values } -async function generateContractDependencies(dependencies) { - const values = [] +async function generateContractDependencies( + dependencies: InteractionTemplateDependency[] +): Promise { + const values: any[] = [] for (let i = 0; i < dependencies.length; i++) { const dependency = dependencies[i] const contracts = [] @@ -44,13 +57,17 @@ async function generateContractDependencies(dependencies) { /** * @description Generates Interaction Template ID for a given Interaction Template * - * @param {object} params - * @param {object} params.template - Interaction Template - * @returns {Promise} - Interaction Template ID + * @param params + * @param params.template Interaction Template + * @returns Interaction Template ID */ -export async function generateTemplateId({template}) { +export async function generateTemplateId({ + template, +}: { + template: InteractionTemplate110 +}): Promise { invariant( - template, + !!template, "generateTemplateId({ template }) -- template must be defined" ) invariant( @@ -69,35 +86,46 @@ export async function generateTemplateId({template}) { const templateData = template.data const messages = await Promise.all( - templateData.messages.map(async templateMessage => [ - genHash(templateMessage.key), - await Promise.all( - templateMessage.i18n.map(async templateMessagei18n => [ - genHash(templateMessagei18n.tag), - genHash(templateMessagei18n.translation), - ]) - ), - ]) + templateData.messages.map( + async (templateMessage: InteractionTemplateMessage) => [ + genHash(templateMessage.key), + await Promise.all( + templateMessage.i18n.map( + async (templateMessagei18n: InteractionTemplateI18n) => [ + genHash(templateMessagei18n.tag), + genHash(templateMessagei18n.translation), + ] + ) + ), + ] + ) ) const params = await Promise.all( templateData?.["parameters"] - .sort((a, b) => a.index - b.index) - .map(async arg => [ + .sort( + (a: InteractionTemplateParameter, b: InteractionTemplateParameter) => + a.index - b.index + ) + .map(async (arg: InteractionTemplateParameter) => [ genHash(arg.label), [ genHash(String(arg.index)), genHash(arg.type), await Promise.all( - arg.messages.map(async argumentMessage => [ - genHash(argumentMessage.key), - await Promise.all( - argumentMessage.i18n.map(async argumentMessagei18n => [ - genHash(argumentMessagei18n.tag), - genHash(argumentMessagei18n.translation), - ]) - ), - ]) + arg.messages.map( + async (argumentMessage: InteractionTemplateMessage) => [ + genHash(argumentMessage.key), + await Promise.all( + argumentMessage.i18n.map( + async (argumentMessagei18n: InteractionTemplateI18n) => [ + genHash(argumentMessagei18n.tag), + genHash(argumentMessagei18n.translation), + ] + ) + ), + ] + ) ), ], ]) diff --git a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id.js b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id.ts similarity index 64% rename from packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id.js rename to packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id.ts index dbba8af2c..a8ca74b27 100644 --- a/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id.js +++ b/packages/fcl-core/src/interaction-template-utils/generate-template-id/generate-template-id.ts @@ -1,17 +1,22 @@ import {invariant} from "@onflow/util-invariant" -import {generateTemplateId as generateTemplateId100} from "./generate-template-id-1.0.0.js" -import {generateTemplateId as generateTemplateId110} from "./generate-template-id-1.1.0.js" +import {generateTemplateId as generateTemplateId100} from "./generate-template-id-1.0.0" +import {generateTemplateId as generateTemplateId110} from "./generate-template-id-1.1.0" +import type {InteractionTemplate} from "../interaction-template" /** * @description Generates Interaction Template ID for a given Interaction Template * - * @param {object} params - * @param {object} params.template - Interaction Template - * @returns {Promise} - Interaction Template ID + * @param params + * @param params.template Interaction Template + * @returns Interaction Template ID */ -export async function generateTemplateId({template}) { +export async function generateTemplateId({ + template, +}: { + template: InteractionTemplate +}): Promise { invariant( - template, + !!template, "generateTemplateId({ template }) -- template must be defined" ) invariant( @@ -36,11 +41,14 @@ export async function generateTemplateId({template}) { /** * @description Verifies the given Interaction Template Id has been correctly generated * - * @param {object} params - * @param {object} params.template - Interaction Template - * @returns {Promise} - true or false, Interaction Template ID + * @param params + * @param params.template Interaction Template + * @returns true or false, Interaction Template ID */ - -export async function verifyGeneratedTemplateId({template}) { +export async function verifyGeneratedTemplateId({ + template, +}: { + template: InteractionTemplate +}): Promise { return template.id === (await generateTemplateId({template})) } diff --git a/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.js b/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.ts similarity index 55% rename from packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.js rename to packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.ts index 633f8e3d1..e38634f0b 100644 --- a/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.js +++ b/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.ts @@ -1,23 +1,72 @@ import {config, invariant} from "@onflow/sdk" import {log, LEVELS} from "@onflow/util-logger" import {query} from "../exec/query" -import {generateTemplateId} from "./generate-template-id/generate-template-id.js" +import {generateTemplateId} from "./generate-template-id/generate-template-id" import {getChainId} from "../utils" +import type {InteractionTemplate} from "./interaction-template" + +export interface GetInteractionTemplateAuditsParams { + template: InteractionTemplate + auditors?: string[] +} + +export interface GetInteractionTemplateAuditsOpts { + flowInteractionAuditContract?: string +} /** - * @description Returns whether a set of auditors have audited a given Interaction Template + * @description Checks whether a set of auditors have audited a given Interaction Template on the Flow + * blockchain. This function validates that the provided interaction template has been properly audited + * for security by trusted auditors before execution. It queries the Flow blockchain's audit contract + * to verify audit status. + * + * @param params + * @param params.template The Interaction Template to check audits for. Must be + * a valid InteractionTemplate object with f_type "InteractionTemplate" + * @param params.auditors Array of auditor addresses to check. If not provided, will use + * auditors from configuration 'flow.auditors' + * @param opts Optional configuration parameters + * @param opts.flowInteractionAuditContract Override address for the FlowInteractionAudit + * contract if not using network defaults + * + * @returns Promise that resolves to an object mapping auditor + * addresses to boolean values indicating whether they have audited the template + * + * @throws If template is invalid, template ID cannot be recomputed, network is unsupported, + * or required configuration is missing + * + * @example + * // Check if template has been audited by specific auditors + * import * as fcl from "@onflow/fcl" + * + * const template = { + * f_type: "InteractionTemplate", + * f_version: "1.1.0", + * id: "template-id-123", + * data: { + * type: "transaction", + * interface: "...", + * cadence: "transaction { ... }" + * } + * } + * + * const auditorAddresses = [ + * "0x1234567890abcdef", + * "0xabcdef1234567890" + * ] + * + * const auditResults = await fcl.InteractionTemplateUtils.getInteractionTemplateAudits({ + * template, + * auditors: auditorAddresses + * }) * - * @param {object} params - * @param {object} params.template - Interaction Template - * @param {Array} params.auditors - Array of auditors - * @param {object} opts - * @param {string} opts.flowInteractionAuditContract - Flow Interaction Template Audit contract address - * @returns {Promise} - Object of auditor addresses and audit status + * console.log(auditResults) + * // { "0x1234567890abcdef": true, "0xabcdef1234567890": false } */ export async function getInteractionTemplateAudits( - {template, auditors}, - opts = {} -) { + {template, auditors}: GetInteractionTemplateAuditsParams, + opts: GetInteractionTemplateAuditsOpts = {} +): Promise> { invariant( template != undefined, "getInteractionTemplateAudits({ template }) -- template must be defined" @@ -79,7 +128,7 @@ export async function getInteractionTemplateAudits( return FlowInteractionTemplateAudit.getHasTemplateBeenAuditedByAuditors(templateId: templateId, auditors: auditors) } `, - args: (arg, t) => [ + args: (arg: any, t: any) => [ arg(recomputedTemplateID, t.String), arg(_auditors, t.Array(t.Address)), ], diff --git a/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.test.js b/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.test.ts similarity index 97% rename from packages/fcl-core/src/interaction-template-utils/get-template-argument-message.test.js rename to packages/fcl-core/src/interaction-template-utils/get-template-argument-message.test.ts index 9073c0780..8d1c5971a 100644 --- a/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.test.js +++ b/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.test.ts @@ -1,7 +1,7 @@ -import {getTemplateArgumentMessage} from "./get-template-argument-message.js" +import {getTemplateArgumentMessage} from "./get-template-argument-message" describe("Get interaction template argument messages", () => { - const templatev1 = { + const templatev1: any = { f_type: "InteractionTemplate", f_version: "1.0.0", id: "abc123", @@ -73,7 +73,7 @@ describe("Get interaction template argument messages", () => { localization: "en-US", argumentLabel: "amount", messageKey: "title", - template: templatev1, + template: templatev1 as any, }) expect(message).toEqual("The amount of FLOW tokens to send") @@ -84,7 +84,7 @@ describe("Get interaction template argument messages", () => { localization: "en-US", argumentLabel: "foo", messageKey: "title", - template: templatev1, + template: templatev1 as any, }) expect(message).toEqual(undefined) @@ -95,7 +95,7 @@ describe("Get interaction template argument messages", () => { localization: "en-US", argumentLabel: "amount", messageKey: "baz", - template: templatev1, + template: templatev1 as any, }) expect(message).toEqual(undefined) @@ -103,7 +103,7 @@ describe("Get interaction template argument messages", () => { }) describe("Get interaction template v1.1.0 parameters messages", () => { - const templatev11 = { + const templatev11: any = { f_type: "InteractionTemplate", f_version: "1.1.0", id: "3a99af243b85f3f6af28304af2ed53a37fb913782b3efc483e6f0162a47720a0", diff --git a/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.js b/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.ts similarity index 67% rename from packages/fcl-core/src/interaction-template-utils/get-template-argument-message.js rename to packages/fcl-core/src/interaction-template-utils/get-template-argument-message.ts index 9112ae7f1..74de00cc6 100644 --- a/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.js +++ b/packages/fcl-core/src/interaction-template-utils/get-template-argument-message.ts @@ -1,21 +1,29 @@ import {invariant} from "@onflow/sdk" +import type {InteractionTemplate} from "./interaction-template" + +export interface GetTemplateArgumentMessageParams { + localization?: string + argumentLabel: string + messageKey: string + template: InteractionTemplate +} /** * @description Gets Interaction Template argument message by message key, argument label, and localization * - * @param {object} opts - * @param {string} opts.localization [localization="en-US"] - Localization to get message for - * @param {string} opts.argumentLabel - Argument label to get message for - * @param {string} opts.messageKey - Message key to get message for - * @param {object} opts.template - Interaction Template to get message from - * @returns {string} - Message + * @param params + * @param params.localization [localization="en-US"] Localization to get message for + * @param params.argumentLabel Argument label to get message for + * @param params.messageKey Message key to get message for + * @param params.template Interaction Template to get message from + * @returns Message */ export function getTemplateArgumentMessage({ localization = "en-US", argumentLabel, messageKey, template, -}) { +}: GetTemplateArgumentMessageParams): string | undefined { invariant( messageKey, "getTemplateArgumentMessage({ messageKey }) -- messageKey must be defined" @@ -30,7 +38,7 @@ export function getTemplateArgumentMessage({ "getTemplateArgumentMessage({ argumentLabel }) -- argumentLabel must be defined" ) invariant( - typeof messageKey === "string", + typeof argumentLabel === "string", "getTemplateArgumentMessage({ argumentLabel }) -- argumentLabel must be a string" ) @@ -52,19 +60,19 @@ export function getTemplateArgumentMessage({ "getTemplateArgumentMessage({ template }) -- template must be an object" ) invariant( - typeof template.f_type === "InteractionTemplate", + template.f_type === "InteractionTemplate", "getTemplateArgumentMessage({ template }) -- template object must be an InteractionTemplate" ) switch (template.f_version) { case "1.1.0": const param = template?.data?.parameters?.find( - a => a.label === argumentLabel + (a: any) => a.label === argumentLabel ) if (!param) return undefined - const message = param?.messages?.find(a => a.key === messageKey) + const message = param?.messages?.find((a: any) => a.key === messageKey) if (!message) return undefined - const lzn = message?.i18n?.find(a => a.tag === localization) + const lzn = message?.i18n?.find((a: any) => a.tag === localization) if (!lzn) return undefined return lzn.translation case "1.0.0": diff --git a/packages/fcl-core/src/interaction-template-utils/get-template-message.test.js b/packages/fcl-core/src/interaction-template-utils/get-template-message.test.ts similarity index 98% rename from packages/fcl-core/src/interaction-template-utils/get-template-message.test.js rename to packages/fcl-core/src/interaction-template-utils/get-template-message.test.ts index 678c1b512..f7dced3d9 100644 --- a/packages/fcl-core/src/interaction-template-utils/get-template-message.test.js +++ b/packages/fcl-core/src/interaction-template-utils/get-template-message.test.ts @@ -1,7 +1,7 @@ -import {getTemplateMessage} from "./get-template-message.js" +import {getTemplateMessage} from "./get-template-message" describe("Get interaction template messages 1.0.0", () => { - const template = { + const template: any = { f_type: "InteractionTemplate", f_version: "1.0.0", id: "abc123", @@ -98,7 +98,7 @@ describe("Get interaction template messages 1.0.0", () => { }) describe("Get interaction template messages 1.1.0", () => { - const template = { + const template: any = { f_type: "InteractionTemplate", f_version: "1.1.0", id: "3a99af243b85f3f6af28304af2ed53a37fb913782b3efc483e6f0162a47720a0", diff --git a/packages/fcl-core/src/interaction-template-utils/get-template-message.js b/packages/fcl-core/src/interaction-template-utils/get-template-message.ts similarity index 63% rename from packages/fcl-core/src/interaction-template-utils/get-template-message.js rename to packages/fcl-core/src/interaction-template-utils/get-template-message.ts index a88cb5de3..182c5a7fa 100644 --- a/packages/fcl-core/src/interaction-template-utils/get-template-message.js +++ b/packages/fcl-core/src/interaction-template-utils/get-template-message.ts @@ -1,21 +1,28 @@ -import {invariant} from "@onflow/sdk" +import {invariant} from "@onflow/util-invariant" +import type {InteractionTemplate} from "./interaction-template" + +export interface GetTemplateMessageParams { + localization?: string + messageKey: string + template: InteractionTemplate +} /** * @description Get Interaction Template argument message * - * @param {object} params - * @param {string} params.localization [localization="en-US"] - Localization code - * @param {string} params.messageKey - Message key - * @param {object} params.template - Interaction Template - * @returns {string} - Message + * @param params + * @param params.localization [localization="en-US"] Localization code + * @param params.messageKey Message key + * @param params.template Interaction Template + * @returns Message */ export function getTemplateMessage({ localization = "en-US", messageKey, template, -}) { +}: GetTemplateMessageParams): string | undefined { invariant( - messageKey, + messageKey as any, "getTemplateMessage({ messageKey }) -- messageKey must be defined" ) invariant( @@ -24,7 +31,7 @@ export function getTemplateMessage({ ) invariant( - localization, + localization as any, "getTemplateMessage({ localization }) -- localization must be defined" ) invariant( @@ -41,15 +48,17 @@ export function getTemplateMessage({ "getTemplateMessage({ template }) -- template must be an object" ) invariant( - typeof template.f_type === "InteractionTemplate", + template.f_type === "InteractionTemplate", "getTemplateMessage({ template }) -- template object must be an InteractionTemplate" ) switch (template.f_version) { case "1.1.0": - const msg = template?.data?.messages?.find(a => a.key === messageKey) + const msg = template?.data?.messages?.find( + (a: any) => a.key === messageKey + ) if (!msg) return undefined - const lzn = msg?.i18n?.find(a => a.tag === localization) + const lzn = msg?.i18n?.find((a: any) => a.tag === localization) if (!lzn) return undefined return lzn.translation case "1.0.0": diff --git a/packages/fcl-core/src/interaction-template-utils/index.js b/packages/fcl-core/src/interaction-template-utils/index.ts similarity index 58% rename from packages/fcl-core/src/interaction-template-utils/index.js rename to packages/fcl-core/src/interaction-template-utils/index.ts index 5187a1265..2174369b9 100644 --- a/packages/fcl-core/src/interaction-template-utils/index.js +++ b/packages/fcl-core/src/interaction-template-utils/index.ts @@ -1,16 +1,16 @@ -export {getInteractionTemplateAudits} from "./get-interaction-template-audits.js" +export {getInteractionTemplateAudits} from "./get-interaction-template-audits" export { generateDependencyPin, generateDependencyPinAtLatestSealedBlock, -} from "./generate-dependency-pin/generate-dependency-pin.js" +} from "./generate-dependency-pin/generate-dependency-pin" export { generateTemplateId, verifyGeneratedTemplateId, -} from "./generate-template-id/generate-template-id.js" +} from "./generate-template-id/generate-template-id" export { verifyDependencyPinsSame, verifyDependencyPinsSameAtLatestSealedBlock, -} from "./verify-dependency-pin-same-at-block.js" -export {deriveCadenceByNetwork} from "./derive-cadence-by-network/derive-cadence-by-network.js" -export {getTemplateMessage} from "./get-template-message.js" -export {getTemplateArgumentMessage} from "./get-template-argument-message.js" +} from "./verify-dependency-pin-same-at-block" +export {deriveCadenceByNetwork} from "./derive-cadence-by-network/derive-cadence-by-network" +export {getTemplateMessage} from "./get-template-message" +export {getTemplateArgumentMessage} from "./get-template-argument-message" diff --git a/packages/fcl-core/src/interaction-template-utils/interaction-template.ts b/packages/fcl-core/src/interaction-template-utils/interaction-template.ts new file mode 100644 index 000000000..018b12113 --- /dev/null +++ b/packages/fcl-core/src/interaction-template-utils/interaction-template.ts @@ -0,0 +1,147 @@ +export interface ImportItem { + contractName: string + address: string + contract: string +} + +export interface InteractionTemplateI18n { + tag: string + translation: string +} + +export interface InteractionTemplateMessage { + key: string + i18n: InteractionTemplateI18n[] +} + +export interface InteractionTemplateParameter { + label: string + index: number + type: string + balance?: string + messages: InteractionTemplateMessage[] +} + +export interface InteractionTemplateNetwork { + network: string + address: string + dependency_pin?: string + dependency_pin_block_height?: number +} + +export interface InteractionTemplateContract { + contract: string + networks: InteractionTemplateNetwork[] +} + +export interface InteractionTemplateDependency { + contracts: InteractionTemplateContract[] +} + +export interface InteractionTemplateCadence { + body: string +} + +// Version 1.0.0 specific types +export interface InteractionTemplateData100 { + type: string + interface: string + messages: Record}> + cadence: string + dependencies: Record< + string, + Record< + string, + Record< + string, + { + address: string + contract: string + fq_address: string + pin: string + pin_block_height: number + } + > + > + > + arguments: Record< + string, + { + index: number + type: string + balance?: string + messages: Record}> + } + > +} + +// Version 1.1.0 specific types +export interface InteractionTemplateData110 { + type: string + interface: string + messages: InteractionTemplateMessage[] + cadence: InteractionTemplateCadence + dependencies: InteractionTemplateDependency[] + parameters: InteractionTemplateParameter[] +} + +export interface InteractionTemplate100 { + f_type: "InteractionTemplate" + f_version: "1.0.0" + id: string + data: InteractionTemplateData100 +} + +export interface InteractionTemplate110 { + f_type: "InteractionTemplate" + f_version: "1.1.0" + id: string + data: InteractionTemplateData110 +} + +export type InteractionTemplate = + | InteractionTemplate100 + | InteractionTemplate110 + +// Utility types for function parameters +export interface GenerateTemplateIdParams { + template: InteractionTemplate +} + +export interface GetInteractionTemplateAuditsParams { + template: InteractionTemplate + auditors?: string[] +} + +export interface GetInteractionTemplateAuditsOpts { + flowInteractionAuditContract?: string +} + +export interface DeriveCadenceByNetworkParams { + network: string + template: InteractionTemplate +} + +export interface GetTemplateMessageParams { + localization?: string + messageKey: string + template: InteractionTemplate +} + +export interface GetTemplateArgumentMessageParams { + localization?: string + argumentLabel: string + messageKey: string + template: InteractionTemplate +} + +export interface GenerateDependencyPinParams { + address: string + contractName: string + blockHeight: number +} + +export interface VerifyDependencyPinsSameParams { + template: InteractionTemplate + blockHeight?: number +} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/find-imports.js b/packages/fcl-core/src/interaction-template-utils/utils/find-imports.js deleted file mode 100644 index 87d55a764..000000000 --- a/packages/fcl-core/src/interaction-template-utils/utils/find-imports.js +++ /dev/null @@ -1,27 +0,0 @@ -import {generateImport} from "./generate-import.js" - -export function findImports(cadence) { - const imports = [] - - const importsReg = /import ((\w|,| )+)* from 0x\w+/g - const fileImports = cadence.match(importsReg) || [] - - for (const fileImport of fileImports) { - const importLineReg = /import ((\w+|, |)*) from (0x\w+)/g - const importLine = importLineReg.exec(fileImport) - - const contractsReg = /((?:\w+)+),?/g - const contracts = importLine[1].match(contractsReg) || [] - - for (const contract of contracts) { - imports.push( - generateImport({ - address: importLine[3], - contractName: contract.replace(/,/g, ""), - }) - ) - } - } - - return imports -} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/find-imports.test.js b/packages/fcl-core/src/interaction-template-utils/utils/find-imports.test.ts similarity index 95% rename from packages/fcl-core/src/interaction-template-utils/utils/find-imports.test.js rename to packages/fcl-core/src/interaction-template-utils/utils/find-imports.test.ts index af68a88d0..2fbbc7f89 100644 --- a/packages/fcl-core/src/interaction-template-utils/utils/find-imports.test.js +++ b/packages/fcl-core/src/interaction-template-utils/utils/find-imports.test.ts @@ -1,5 +1,5 @@ -import {findImports} from "./find-imports.js" -import {generateImport} from "./generate-import.js" +import {findImports} from "./find-imports" +import {generateImport} from "./generate-import" describe("Find imports", () => { const cadenceA = ` diff --git a/packages/fcl-core/src/interaction-template-utils/utils/find-imports.ts b/packages/fcl-core/src/interaction-template-utils/utils/find-imports.ts new file mode 100644 index 000000000..63ed43a94 --- /dev/null +++ b/packages/fcl-core/src/interaction-template-utils/utils/find-imports.ts @@ -0,0 +1,55 @@ +import {generateImport} from "./generate-import" +import {ImportItem} from "../interaction-template" + +/** + * @description Parses a Cadence script or transaction to find all import statements and extracts + * the contract names and addresses. This function uses regular expressions to identify import + * statements and creates ImportItem objects for each imported contract. + * + * @param cadence The Cadence code string to parse for import statements + * @returns Array of ImportItem objects containing contract names and addresses + * + * @example + * // Parse imports from Cadence code + * const cadenceCode = ` + * import FlowToken from 0x1654653399040a61 + * import FungibleToken, NonFungibleToken from 0x9a0766d93b6608b7 + * + * transaction() { + * // transaction code + * } + * ` + * + * const imports = findImports(cadenceCode) + * console.log(imports) + * // [ + * // { contractName: "FlowToken", address: "0x1654653399040a61", contract: "" }, + * // { contractName: "FungibleToken", address: "0x9a0766d93b6608b7", contract: "" }, + * // { contractName: "NonFungibleToken", address: "0x9a0766d93b6608b7", contract: "" } + * // ] + */ +export function findImports(cadence: string): ImportItem[] { + const imports: ImportItem[] = [] + + const importsReg = /import ((\w|,| )+)* from 0x\w+/g + const fileImports = cadence.match(importsReg) || [] + + for (const fileImport of fileImports) { + const importLineReg = /import ((\w+|, |)*) from (0x\w+)/g + const importLine = importLineReg.exec(fileImport) + + const contractsReg = /((?:\w+)+),?/g + const contracts = importLine?.[1].match(contractsReg) || [] + + for (const contract of contracts) { + imports.push( + generateImport({ + address: importLine?.[3]!, + contractName: contract.replace(/,/g, ""), + }) + ) + } + } + + return imports +} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/generate-import.js b/packages/fcl-core/src/interaction-template-utils/utils/generate-import.js deleted file mode 100644 index de0227dee..000000000 --- a/packages/fcl-core/src/interaction-template-utils/utils/generate-import.js +++ /dev/null @@ -1,3 +0,0 @@ -export function generateImport({contractName, address}) { - return {contractName, address, contract: ""} -} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/generate-import.ts b/packages/fcl-core/src/interaction-template-utils/utils/generate-import.ts new file mode 100644 index 000000000..aa6271a15 --- /dev/null +++ b/packages/fcl-core/src/interaction-template-utils/utils/generate-import.ts @@ -0,0 +1,32 @@ +import {ImportItem} from "../interaction-template" + +export interface GenerateImportParams { + contractName: string + address: string +} + +/** + * @description Creates an ImportItem object from a contract name and address. This is a utility + * function used to generate standardized import objects for interaction templates and dependency + * management. The contract field is initialized as an empty string. + * + * @param params The parameters object containing contract details + * @param params.contractName The name of the contract being imported + * @param params.address The Flow address where the contract is deployed + * @returns ImportItem object with contractName, address, and empty contract field + * + * @example + * // Generate import for FlowToken contract + * const importItem = generateImport({ + * contractName: "FlowToken", + * address: "0x1654653399040a61" + * }) + * console.log(importItem) + * // { contractName: "FlowToken", address: "0x1654653399040a61", contract: "" } + */ +export function generateImport({ + contractName, + address, +}: GenerateImportParams): ImportItem { + return {contractName, address, contract: ""} +} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/hash.js b/packages/fcl-core/src/interaction-template-utils/utils/hash.js deleted file mode 100644 index 14917c2b4..000000000 --- a/packages/fcl-core/src/interaction-template-utils/utils/hash.js +++ /dev/null @@ -1,8 +0,0 @@ -import {SHA3} from "sha3" -import {Buffer} from "@onflow/rlp" - -export function genHash(utf8String) { - const sha = new SHA3(256) - sha.update(Buffer.from(utf8String, "utf8")) - return sha.digest("hex") -} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/hash.ts b/packages/fcl-core/src/interaction-template-utils/utils/hash.ts new file mode 100644 index 000000000..4f8778f0e --- /dev/null +++ b/packages/fcl-core/src/interaction-template-utils/utils/hash.ts @@ -0,0 +1,22 @@ +import {SHA3} from "sha3" +import {Buffer} from "@onflow/rlp" + +/** + * @description Generates a SHA3-256 hash of a UTF-8 string. This function is commonly used in Flow + * for creating deterministic hashes of Cadence code, interaction templates, and other string data + * that need to be uniquely identified or verified for integrity. + * + * @param utf8String The UTF-8 string to hash + * @returns The SHA3-256 hash of the input string as a hexadecimal string + * + * @example + * // Generate hash of Cadence code + * const cadenceCode = "access(all) fun main(): String { return \"Hello\" }" + * const hash = genHash(cadenceCode) + * console.log(hash) // "a1b2c3d4e5f6..." (64-character hex string) + */ +export function genHash(utf8String: string): string { + const sha = new SHA3(256) + sha.update(Buffer.from(utf8String, "utf8")) + return sha.digest("hex") +} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.js b/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.js deleted file mode 100644 index cb347ade8..000000000 --- a/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @description - Replaces string imports with the actual contract address - * - * @param {object} param - * @param {string} param.cadence - * @param {object} param.networkDependencies - * @returns {string} - Cadence - */ -export function replaceStringImports({cadence, networkDependencies}) { - return Object.keys(networkDependencies).reduce((c, contractName) => { - const address = networkDependencies[contractName] - const regex = new RegExp(`import "\\b${contractName}\\b"`, "g") - return c.replace(regex, `import ${contractName} from ${address}`) - }, cadence) -} diff --git a/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.test.js b/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.test.ts similarity index 100% rename from packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.test.js rename to packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.test.ts diff --git a/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.ts b/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.ts new file mode 100644 index 000000000..6e8fa3139 --- /dev/null +++ b/packages/fcl-core/src/interaction-template-utils/utils/replace-string-imports.ts @@ -0,0 +1,23 @@ +export interface ReplaceStringImportsParams { + cadence: string + networkDependencies: Record +} + +/** + * @description Replaces string imports with the actual contract address + * + * @param params + * @param params.cadence The Cadence code + * @param params.networkDependencies Network dependencies mapping + * @returns Cadence code with replaced imports + */ +export function replaceStringImports({ + cadence, + networkDependencies, +}: ReplaceStringImportsParams): string { + return Object.keys(networkDependencies).reduce((c, contractName) => { + const address = networkDependencies[contractName] + const regex = new RegExp(`import "\\b${contractName}\\b"`, "g") + return c.replace(regex, `import ${contractName} from ${address}`) + }, cadence) +} diff --git a/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.test.js b/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.test.ts similarity index 98% rename from packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.test.js rename to packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.test.ts index 945d3fbac..87d6ecc31 100644 --- a/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.test.js +++ b/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.test.ts @@ -1,4 +1,4 @@ -import {verifyDependencyPinsSame} from "./verify-dependency-pin-same-at-block.js" +import {verifyDependencyPinsSame} from "./verify-dependency-pin-same-at-block" import {config} from "@onflow/config" const returnedAccount = { @@ -418,12 +418,14 @@ const template = { } describe("1.1.0, verify dependency pin same", () => { + let consoleWarnSpy + beforeAll(() => { - jest.spyOn(console, "warn").mockImplementation(() => {}) + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) }) afterAll(() => { - console.warn.mockRestore() + consoleWarnSpy.mockRestore() }) test("v1.1.0, get dependency pin", async () => { @@ -431,7 +433,7 @@ describe("1.1.0, verify dependency pin same", () => { config.put("accessNode.api", "https://rest-mainnet.onflow.org") const isVerified = await verifyDependencyPinsSame({ - template: template, + template: template as any, blockHeight: 70493190, network: "mainnet", }) diff --git a/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.js b/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.ts similarity index 79% rename from packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.js rename to packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.ts index 2863988e1..92d2fab6d 100644 --- a/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.js +++ b/packages/fcl-core/src/interaction-template-utils/verify-dependency-pin-same-at-block.ts @@ -1,21 +1,37 @@ -import {generateDependencyPin} from "./generate-dependency-pin/generate-dependency-pin.js" +import {generateDependencyPin} from "./generate-dependency-pin/generate-dependency-pin" import {invariant, block} from "@onflow/sdk" import {log, LEVELS} from "@onflow/util-logger" +import {InteractionTemplate} from "./interaction-template" + +export interface VerifyDependencyPinsSameParams { + template: InteractionTemplate + blockHeight?: number + network: string +} + +export interface VerifyDependencyPinsSameOpts { + [key: string]: any +} + +export interface VerifyDependencyPinsSameAtLatestSealedBlockParams { + template: InteractionTemplate + network: string +} /** * @description Checks if an Interaction Template's pins match those generated at a block height * - * @param {object} params - * @param {object} params.template - Interaction Template to check pins for - * @param {number} params.blockHeight - Block height to check pins at - * @param {string} params.network - Network to check pins on - * @param {object} opts - * @returns {Promise} - Whether or not the pins match + * @param params + * @param params.template Interaction Template to check pins for + * @param params.blockHeight Block height to check pins at + * @param params.network Network to check pins on + * @param opts + * @returns Whether or not the pins match */ export async function verifyDependencyPinsSame( - {template, blockHeight, network}, - opts = {} -) { + {template, blockHeight, network}: VerifyDependencyPinsSameParams, + opts: VerifyDependencyPinsSameOpts = {} +): Promise { invariant( template != undefined, "generateDependencyPin({ template }) -- template must be defined" @@ -115,13 +131,13 @@ export async function verifyDependencyPinsSame( opts ) - if (pin !== net.dependency_pin.pin) { + if (pin !== (net as any).dependency_pin.pin) { log({ title: "verifyDependencyPinsSame Debug Error", message: `Could not recompute and match dependency pin. address: ${net.address} | contract: ${contract.contract} computed: ${pin} - template: ${net.pin} + template: ${(net as any).pin} `, level: LEVELS.debug, }) @@ -145,16 +161,16 @@ export async function verifyDependencyPinsSame( /** * @description Checks if an Interaction Template's pins match those generated at the latest block height * - * @param {object} params - * @param {object} params.template - Interaction Template to check pins for - * @param {string} params.network - Network to check pins on - * @param {object} opts - * @returns {Promise} - Whether or not the pins match + * @param params + * @param params.template Interaction Template to check pins for + * @param params.network Network to check pins on + * @param opts + * @returns Whether or not the pins match */ export async function verifyDependencyPinsSameAtLatestSealedBlock( - {template, network}, - opts = {} -) { + {template, network}: VerifyDependencyPinsSameAtLatestSealedBlockParams, + opts: VerifyDependencyPinsSameOpts = {} +): Promise { const latestSealedBlock = await block({sealed: true}) const latestSealedBlockHeight = latestSealedBlock?.height diff --git a/packages/fcl-core/src/normalizers/service/__vsn.js b/packages/fcl-core/src/normalizers/service/__vsn.ts similarity index 100% rename from packages/fcl-core/src/normalizers/service/__vsn.js rename to packages/fcl-core/src/normalizers/service/__vsn.ts diff --git a/packages/fcl-core/src/normalizers/service/account-proof.js b/packages/fcl-core/src/normalizers/service/account-proof.js deleted file mode 100644 index dc1efca59..000000000 --- a/packages/fcl-core/src/normalizers/service/account-proof.js +++ /dev/null @@ -1,29 +0,0 @@ -// { -// "f_type": "Service", // Its a service! -// "f_vsn": "1.0.0", // Follows the v1.0.0 spec for the service -// "type": "account-proof", // the type of service it is -// "method": "DATA", // Its data! -// "uid": "awesome-wallet#account-proof", // A unique identifier for the service -// "data": { -// "f_type": "account-proof", -// "f_vsn": "1.0.0", -// "nonce": "0A1BC2FF", // Nonce signed by the current account-proof (minimum 32 bytes in total, i.e 64 hex characters) -// "address": "0xUSER", // The user's address (8 bytes, i.e 16 hex characters) -// "signature": CompositeSignature, // address (sans-prefix), keyId, signature (hex) -// } - -export function normalizeAccountProof(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - throw new Error(`FCL Normalizer Error: Invalid account-proof service`) - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/account-proof.ts b/packages/fcl-core/src/normalizers/service/account-proof.ts new file mode 100644 index 000000000..b4409381d --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/account-proof.ts @@ -0,0 +1,38 @@ +import {Service} from "@onflow/typedefs" + +/** + * @description Normalizes an account-proof service to ensure compatibility with FCL service format + * + * @param service The account-proof service to normalize + * @returns The normalized account-proof service or null + * + * @example + * { + * "f_type": "Service", // Its a service! + * "f_vsn": "1.0.0", // Follows the v1.0.0 spec for the service + * "type": "account-proof", // the type of service it is + * "method": "DATA", // Its data! + * "uid": "awesome-wallet#account-proof", // A unique identifier for the service + * "data": { + * "f_type": "account-proof", + * "f_vsn": "1.0.0", + * "nonce": "0A1BC2FF", // Nonce signed by the current account-proof (minimum 32 bytes in total, i.e 64 hex characters) + * "address": "0xUSER", // The user's address (8 bytes, i.e 16 hex characters) + * "signature": CompositeSignature, // address (sans-prefix), keyId, signature (hex) + * } + */ +export function normalizeAccountProof(service: Service | null): Service | null { + if (service == null) return null + + if (!service["f_vsn"]) { + throw new Error(`FCL Normalizer Error: Invalid account-proof service`) + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/authn-refresh.js b/packages/fcl-core/src/normalizers/service/authn-refresh.js deleted file mode 100644 index 37b7d9d6d..000000000 --- a/packages/fcl-core/src/normalizers/service/authn-refresh.js +++ /dev/null @@ -1,26 +0,0 @@ -// { -// "f_type": "Service", -// "f_vsn": "1.0.0", -// "type": "authn-refresh", -// "uid": "uniqueDedupeKey", -// "endpoint": "https://rawr", -// "method": "HTTP/POST", // "HTTP/POST", // HTTP/POST | IFRAME/RPC | HTTP/RPC -// "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // wallets internal id for the user -// "data": {}, // included in body of request -// "params": {}, // included as query params on endpoint url -// } -export function normalizeAuthnRefresh(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - throw new Error("Invalid authn-refresh service") - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/authn-refresh.ts b/packages/fcl-core/src/normalizers/service/authn-refresh.ts new file mode 100644 index 000000000..4cb44d961 --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/authn-refresh.ts @@ -0,0 +1,47 @@ +import {Service} from "@onflow/typedefs" + +export interface AuthnRefreshService extends Service { + id?: string + addr?: string + name?: string + icon?: string + authn?: string + pid?: string +} + +/** + * @description Normalizes an authn-refresh service to ensure compatibility with FCL service format + * + * @param service The authn-refresh service to normalize + * @returns The normalized authn-refresh service or null + * + * @example + * const service = normalizeAuthnRefresh({ + * f_type: "Service", + * f_vsn: "1.0.0", + * type: "authn-refresh", + * uid: "uniqueDedupeKey", + * endpoint: "https://rawr", + * method: "HTTP/POST", // HTTP/POST | IFRAME/RPC | HTTP/RPC + * id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // wallets internal id for the user + * data: {}, // included in body of request + * params: {}, // included as query params on endpoint url + * }) + */ +export function normalizeAuthnRefresh( + service: AuthnRefreshService | null +): AuthnRefreshService | null { + if (service == null) return null + + if (!service["f_vsn"]) { + throw new Error("Invalid authn-refresh service") + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/authn.js b/packages/fcl-core/src/normalizers/service/authn.js deleted file mode 100644 index eaf60f29a..000000000 --- a/packages/fcl-core/src/normalizers/service/authn.js +++ /dev/null @@ -1,46 +0,0 @@ -import {withPrefix} from "@onflow/util-address" -import {SERVICE_PRAGMA} from "./__vsn" - -// { -// "f_type": "Service", -// "f_vsn": "1.0.0", -// "type": "authn", -// "uid": "uniqueDedupeKey", -// "endpoint": "https://rawr", -// "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // wallets internal id for the user -// "identity": { -// "address": "0x____" -// }, -// "provider": { -// "address": "0x____", -// "name": "Best Wallet", -// "description": "The Best Wallet" -// "icon": "https://", -// } -// } -export function normalizeAuthn(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - return { - ...SERVICE_PRAGMA, - type: service.type, - uid: service.id, - endpoint: service.authn, - id: service.pid, - provider: { - address: withPrefix(service.addr), - name: service.name, - icon: service.icon, - }, - } - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/authn.ts b/packages/fcl-core/src/normalizers/service/authn.ts new file mode 100644 index 000000000..dc25f6a4b --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/authn.ts @@ -0,0 +1,66 @@ +import {Service} from "@onflow/typedefs" +import {withPrefix} from "@onflow/util-address" +import {SERVICE_PRAGMA} from "./__vsn" + +export interface AuthnService extends Service { + id?: string + addr?: string + name?: string + icon?: string + authn?: string + pid?: string +} + +/** + * @description Normalizes an authn service to ensure compatibility with FCL service format + * + * @param service The authn service to normalize + * @returns The normalized authn service or null + * + * @example + * const service = normalizeAuthn({ + * f_type: "Service", + * f_vsn: "1.0.0", + * type: "authn", + * uid: "uniqueDedupeKey", + * endpoint: "https://rawr", + * id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // wallets internal id for the user + * identity: { + * address: "0x____" + * }, + * provider: { + * address: "0x____", + * name: "Best Wallet", + * description: "The Best Wallet", + * icon: "https://", + * } + * }) + */ +export function normalizeAuthn( + service: AuthnService | null +): AuthnService | null { + if (service == null) return null + + if (!service["f_vsn"]) { + return { + ...SERVICE_PRAGMA, + type: service.type, + uid: service.id, + endpoint: service.authn, + id: service.pid, + provider: { + address: withPrefix(service.addr!), + name: service.name, + icon: service.icon, + }, + } as AuthnService + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/authz.js b/packages/fcl-core/src/normalizers/service/authz.js deleted file mode 100644 index bf0a8552b..000000000 --- a/packages/fcl-core/src/normalizers/service/authz.js +++ /dev/null @@ -1,45 +0,0 @@ -import {withPrefix} from "@onflow/util-address" -import {SERVICE_PRAGMA, IDENTITY_PRAGMA} from "./__vsn" - -// { -// "f_type": "service", -// "f_vsn": "1.0.0", -// "type": "authz", -// "uid": "uniqueDedupeKey", -// "endpoint": "https://rawr", -// "method": "HTTP/POST", // HTTP/POST | IFRAME/RPC | HTTP/RPC -// "identity": { -// "address": "0x______", -// "keyId": 0, -// }, -// "data": {}, // included in body of authz request -// "params": {}, // included as query params on endpoint url -// } -export function normalizeAuthz(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - return { - ...SERVICE_PRAGMA, - type: service.type, - uid: service.id, - endpoint: service.endpoint, - method: service.method, - identity: { - ...IDENTITY_PRAGMA, - address: withPrefix(service.addr), - keyId: service.keyId, - }, - params: service.params, - data: service.data, - } - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/authz.ts b/packages/fcl-core/src/normalizers/service/authz.ts new file mode 100644 index 000000000..f9d6fbe5c --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/authz.ts @@ -0,0 +1,63 @@ +import {withPrefix} from "@onflow/util-address" +import {IDENTITY_PRAGMA, SERVICE_PRAGMA} from "./__vsn" +import {Service} from "@onflow/typedefs" + +export interface AuthzService extends Service { + id?: string + addr?: string + keyId?: number + identity?: any +} + +/** + * @description Normalizes an authz service to ensure compatibility with FCL service format + * + * @param service The authz service to normalize + * @returns The normalized authz service or null + * + * @example + * const service = normalizeAuthz({ + * f_type: "service", + * f_vsn: "1.0.0", + * type: "authz", + * uid: "uniqueDedupeKey", + * endpoint: "https://rawr", + * method: "HTTP/POST", // HTTP/POST | IFRAME/RPC | HTTP/RPC + * identity: { + * address: "0x______", + * keyId: 0, + * }, + * data: {}, // included in body of authz request + * params: {}, // included as query params on endpoint url + * }) + */ +export function normalizeAuthz( + service: AuthzService | null +): AuthzService | null { + if (service == null) return null + + if (!service["f_vsn"]) { + return { + ...SERVICE_PRAGMA, + type: service.type, + uid: service.id, + endpoint: service.endpoint, + method: service.method, + identity: { + ...IDENTITY_PRAGMA, + address: withPrefix(service.addr!), + keyId: service.keyId, + }, + params: service.params, + data: service.data, + } as AuthzService + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/back-channel-rpc.js b/packages/fcl-core/src/normalizers/service/back-channel-rpc.js deleted file mode 100644 index 9f8467104..000000000 --- a/packages/fcl-core/src/normalizers/service/back-channel-rpc.js +++ /dev/null @@ -1,33 +0,0 @@ -import {SERVICE_PRAGMA} from "./__vsn" - -// { -// "f_type": "Service", -// "f_vsn": "1.0.0", -// "type": "back-channel-rpc", -// "endpoint": "https://rawr", -// "method": "HTTP/GET", // HTTP/GET | HTTP/POST -// "data": {}, // included in body of rpc -// "params": {}, // included as query params on endpoint url -// } -export function normalizeBackChannelRpc(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - return { - ...SERVICE_PRAGMA, - type: "back-channel-rpc", - endpoint: service.endpoint, - method: service.method, - params: service.params || {}, - data: service.data || {}, - } - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/back-channel-rpc.ts b/packages/fcl-core/src/normalizers/service/back-channel-rpc.ts new file mode 100644 index 000000000..d7c4ae49b --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/back-channel-rpc.ts @@ -0,0 +1,44 @@ +import {SERVICE_PRAGMA} from "./__vsn" +import {Service} from "@onflow/typedefs" + +/** + * @description Normalizes a back-channel-rpc service to ensure compatibility with FCL service format + * + * @param service The back-channel-rpc service to normalize + * @returns The normalized back-channel-rpc service or null + * + * @example + * const service = normalizeBackChannelRpc({ + * f_type: "Service", + * f_vsn: "1.0.0", + * type: "back-channel-rpc", + * endpoint: "https://rawr", + * method: "HTTP/GET", // HTTP/GET | HTTP/POST + * data: {}, // included in body of rpc + * params: {}, // included as query params on endpoint url + * }) + */ +export function normalizeBackChannelRpc( + service: Service | null +): Service | null { + if (service == null) return null + + if (!service["f_vsn"]) { + return { + ...SERVICE_PRAGMA, + type: "back-channel-rpc", + endpoint: service.endpoint, + method: service.method, + params: service.params || {}, + data: service.data || {}, + } as Service + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/composite-signature.js b/packages/fcl-core/src/normalizers/service/composite-signature.js deleted file mode 100644 index 47aea2a25..000000000 --- a/packages/fcl-core/src/normalizers/service/composite-signature.js +++ /dev/null @@ -1,30 +0,0 @@ -import {COMPOSITE_SIGNATURE_PRAGMA} from "./__vsn" -import {sansPrefix} from "@onflow/util-address" - -// { -// "f_type": "CompositeSignature", -// "f_vsn": "1.0.0", -// "addr": "_____", // sans-prefix -// "signature": "adfe1234", // hex -// "keyId": 3, -// } -export function normalizeCompositeSignature(resp) { - if (resp == null) return null - - if (!resp["f_vsn"]) { - return { - ...COMPOSITE_SIGNATURE_PRAGMA, - addr: sansPrefix(resp.addr || resp.address), - signature: resp.signature || resp.sig, - keyId: resp.keyId, - } - } - - switch (resp["f_vsn"]) { - case "1.0.0": - return resp - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/composite-signature.ts b/packages/fcl-core/src/normalizers/service/composite-signature.ts new file mode 100644 index 000000000..9cd8a472d --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/composite-signature.ts @@ -0,0 +1,41 @@ +import {CompositeSignature} from "@onflow/typedefs" +import {sansPrefix} from "@onflow/util-address" +import {COMPOSITE_SIGNATURE_PRAGMA} from "./__vsn" + +/** + * @description Normalizes a composite signature to ensure compatibility with FCL format + * + * @param resp The composite signature to normalize + * @returns The normalized composite signature or null + * + * @example + * const resp = normalizeCompositeSignature({ + * f_type: "CompositeSignature", + * f_vsn: "1.0.0", + * addr: "_____", // sans-prefix + * signature: "adfe1234", // hex + * keyId: 3, + * }) + */ +export function normalizeCompositeSignature( + resp: any +): CompositeSignature | null { + if (resp == null) return null + + if (!resp["f_vsn"]) { + return { + ...COMPOSITE_SIGNATURE_PRAGMA, + addr: sansPrefix(resp.addr || (resp as any).address), + signature: resp.signature || (resp as any).sig, + keyId: resp.keyId, + } as unknown as CompositeSignature + } + + switch (resp["f_vsn"]) { + case "1.0.0": + return resp as unknown as CompositeSignature + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/frame.js b/packages/fcl-core/src/normalizers/service/frame.js deleted file mode 100644 index 076d80b7e..000000000 --- a/packages/fcl-core/src/normalizers/service/frame.js +++ /dev/null @@ -1,32 +0,0 @@ -import {SERVICE_PRAGMA} from "./__vsn" - -// { -// "f_type": "Service", -// "f_vsn": "1.0.0", -// "type": "frame", -// "endpoint": "https://rawr", -// "data": {}, // Sent to frame when ready -// "params": {}, // include in query params on frame -// } -export function normalizeFrame(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - return { - old: service, - ...SERVICE_PRAGMA, - type: "frame", - endpoint: service.endpoint, - params: service.params || {}, - data: service.data || {}, - } - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/frame.ts b/packages/fcl-core/src/normalizers/service/frame.ts new file mode 100644 index 000000000..5173cae46 --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/frame.ts @@ -0,0 +1,45 @@ +import {Service} from "@onflow/typedefs" +import {SERVICE_PRAGMA} from "./__vsn" + +export interface FrameService extends Service { + old?: any +} + +/** + * @description Normalizes a frame service to ensure compatibility with FCL service format + * + * @param service The frame service to normalize + * @returns The normalized frame service or null + * + * @example + * const service = normalizeFrame({ + * f_type: "Service", + * f_vsn: "1.0.0", + * type: "frame", + * endpoint: "https://rawr", + * data: {}, // Sent to frame when ready + * params: {}, // include in query params on frame + * }) + */ +export function normalizeFrame(service: Service | null): FrameService | null { + if (service == null) return null + + if (!service["f_vsn"]) { + return { + old: service, + ...SERVICE_PRAGMA, + type: "frame", + endpoint: service.endpoint, + params: service.params || {}, + data: service.data || {}, + } as FrameService + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/local-view.js b/packages/fcl-core/src/normalizers/service/local-view.js deleted file mode 100644 index ce9f37432..000000000 --- a/packages/fcl-core/src/normalizers/service/local-view.js +++ /dev/null @@ -1,36 +0,0 @@ -import {SERVICE_PRAGMA} from "./__vsn" - -// { -// "f_type": "Service", -// "f_vsn": "1.0.0", -// type: "local-view", -// method: "VIEW/IFRAME", -// endpoint: "https://woot.org/authz/local", -// data: {}, -// params: {}, -// } -export function normalizeLocalView(resp) { - if (resp == null) return null - if (resp.method == null) { - resp = {...resp, type: "local-view", method: "VIEW/IFRAME"} - } - - if (!resp["f_vsn"]) { - return { - ...SERVICE_PRAGMA, - type: resp.type || "local-view", - method: resp.method, - endpoint: resp.endpoint, - data: resp.data || {}, - params: resp.params || {}, - } - } - - switch (resp["f_vsn"]) { - case "1.0.0": - return resp - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/local-view.ts b/packages/fcl-core/src/normalizers/service/local-view.ts new file mode 100644 index 000000000..d753b53f2 --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/local-view.ts @@ -0,0 +1,45 @@ +import {Service} from "@onflow/typedefs" +import {SERVICE_PRAGMA} from "./__vsn" + +/** + * @description Normalizes a local-view service to ensure compatibility with FCL format + * + * @param resp The local-view to normalize + * @returns The normalized local-view or null + * + * @example + * const service = normalizeLocalView({ + * f_type: "Service", + * f_vsn: "1.0.0", + * type: "local-view", + * method: "VIEW/IFRAME", + * endpoint: "https://woot.org/authz/local", + * data: {}, + * params: {}, + * }) + */ +export function normalizeLocalView(resp: Service | null): Service | null { + if (resp == null) return null + if (resp.method == null) { + resp = {...resp, type: "local-view", method: "VIEW/IFRAME"} + } + + if (!resp["f_vsn"]) { + return { + ...SERVICE_PRAGMA, + type: resp.type || "local-view", + method: resp.method, + endpoint: resp.endpoint, + data: resp.data || {}, + params: resp.params || {}, + } as Service + } + + switch (resp["f_vsn"]) { + case "1.0.0": + return resp + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/open-id.js b/packages/fcl-core/src/normalizers/service/open-id.js deleted file mode 100644 index 45b98e49d..000000000 --- a/packages/fcl-core/src/normalizers/service/open-id.js +++ /dev/null @@ -1,52 +0,0 @@ -import {SERVICE_PRAGMA, OPEN_ID_PRAGMA} from "./__vsn" - -// { -// "f_type": "Service", -// "f_vsn": "1.0.0", -// "type": "open-id", -// "uid": "uniqueDedupeKey", -// "method: "data", -// "data": { -// "profile": { -// "name": "Bob", -// "family_name": "Builder", -// "given_name": "Robert", -// "middle_name": "the", -// "nickname": "Bob the Builder", -// "perferred_username": "bob", -// "profile": "https://www.bobthebuilder.com/", -// "picture": "https://avatars.onflow.org/avatar/bob", -// "gender": "...", -// "birthday": "2001-01-18", -// "zoneinfo": "America/Vancouver", -// "locale": "en-us", -// "updated_at": "1614970797388" -// }, -// "email": { -// "email": "bob@bob.bob", -// "email_verified": true -// }, -// "address": { -// "address": "One Apple Park Way, Cupertino, CA 95014, USA" -// }, -// "phone": { -// "phone_number": "+1 (xxx) yyy-zzzz", -// "phone_number_verified": true -// }, -// "social": { -// "twitter": "@_qvvg", -// "twitter_verified": true -// }, -// } -// } -export function normalizeOpenId(service) { - if (service == null) return null - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/open-id.ts b/packages/fcl-core/src/normalizers/service/open-id.ts new file mode 100644 index 000000000..3985a19c3 --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/open-id.ts @@ -0,0 +1,60 @@ +import {Service} from "@onflow/typedefs" + +/** + * @description Normalizes an open-id service to ensure compatibility with FCL service format + * + * @param service The open-id service to normalize + * @returns The normalized open-id service or null + * + * @example + * const service = normalizeOpenId({ + * f_type: "Service", + * f_vsn: "1.0.0", + * type: "open-id", + * uid: "uniqueDedupeKey", + * method: "data", + * data: { + * profile: { + * name: "Bob", + * family_name: "Builder", + * given_name: "Robert", + * middle_name: "the", + * nickname: "Bob the Builder", + * preferred_username: "bob", + * profile: "https://www.bobthebuilder.com/", + * picture: "https://avatars.onflow.org/avatar/bob", + * gender: "...", + * birthday: "2001-01-18", + * zoneinfo: "America/Vancouver", + * locale: "en-us", + * updated_at: "1614970797388" + * }, + * email: { + * email: "bob@bob.bob", + * email_verified: true + * }, + * address: { + * address: "One Apple Park Way, Cupertino, CA 95014, USA" + * }, + * phone: { + * phone_number: "+1 (xxx) yyy-zzzz", + * phone_number_verified: true + * }, + * social: { + * twitter: "@_qvvg", + * twitter_verified: true + * }, + * } + * }) + */ +export function normalizeOpenId(service: Service | null): Service | null { + if (service == null) return null + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/polling-response.js b/packages/fcl-core/src/normalizers/service/polling-response.js deleted file mode 100644 index 5e560de4e..000000000 --- a/packages/fcl-core/src/normalizers/service/polling-response.js +++ /dev/null @@ -1,35 +0,0 @@ -import {POLLING_RESPONSE_PRAGMA} from "./__vsn" -import {normalizeBackChannelRpc} from "./back-channel-rpc" -import {normalizeFrame} from "./frame" - -// { -// "f_type": "PollingResponse", -// "f_vsn": "1.0.0", -// "status": "PENDING", // PENDING | APPROVED | DECLINED | REDIRECT -// "reason": null, // Reason for Declining Transaction -// "data": null, // Return value for APPROVED -// "updates": BackChannelRpc, -// "local": Frame, -// } -export function normalizePollingResponse(resp) { - if (resp == null) return null - - if (!resp["f_vsn"]) { - return { - ...POLLING_RESPONSE_PRAGMA, - status: resp.status ?? "APPROVED", - reason: resp.reason ?? null, - data: resp.compositeSignature || resp.data || {...resp} || {}, - updates: normalizeBackChannelRpc(resp.authorizationUpdates), - local: normalizeFrame((resp.local || [])[0]), - } - } - - switch (resp["f_vsn"]) { - case "1.0.0": - return resp - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/polling-response.ts b/packages/fcl-core/src/normalizers/service/polling-response.ts new file mode 100644 index 000000000..b088ff938 --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/polling-response.ts @@ -0,0 +1,58 @@ +import {CompositeSignature} from "@onflow/typedefs" +import {POLLING_RESPONSE_PRAGMA} from "./__vsn" +import {normalizeBackChannelRpc} from "./back-channel-rpc" +import {normalizeFrame} from "./frame" + +export interface PollingResponse { + f_type: "PollingResponse" + f_vsn: "1.0.0" + status: "PENDING" | "APPROVED" | "DECLINED" | "REDIRECT" + reason: string | null + compositeSignature?: CompositeSignature + authorizationUpdates?: any + local: any + data?: any + updates?: any +} + +/** + * @description Normalizes a polling response to ensure compatibility with FCL format + * + * @param resp The polling response to normalize + * @returns The normalized polling response or null + * + * @example + * const resp = normalizePollingResponse({ + * f_type: "PollingResponse", + * f_vsn: "1.0.0", + * status: "PENDING", // PENDING | APPROVED | DECLINED | REDIRECT + * reason: null, // Reason for Declining Transaction + * data: null, // Return value for APPROVED + * updates: BackChannelRpc, + * local: Frame, + * }) + */ +export function normalizePollingResponse( + resp: PollingResponse | null +): PollingResponse | null { + if (resp == null) return null + + if (!resp["f_vsn"]) { + return { + ...POLLING_RESPONSE_PRAGMA, + status: resp.status ?? "APPROVED", + reason: resp.reason ?? null, + data: resp.compositeSignature || resp.data || {...resp} || {}, + updates: normalizeBackChannelRpc(resp.authorizationUpdates), + local: normalizeFrame((resp.local || [])[0]), + } as PollingResponse + } + + switch (resp["f_vsn"]) { + case "1.0.0": + return resp + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/pre-authz.js b/packages/fcl-core/src/normalizers/service/pre-authz.js deleted file mode 100644 index ca0c854fa..000000000 --- a/packages/fcl-core/src/normalizers/service/pre-authz.js +++ /dev/null @@ -1,45 +0,0 @@ -import {withPrefix} from "@onflow/util-address" -import {SERVICE_PRAGMA, IDENTITY_PRAGMA} from "./__vsn" - -// { -// "f_type": "service", -// "f_vsn": "1.0.0", -// "type": "pre-authz", -// "uid": "uniqueDedupeKey", -// "endpoint": "https://rawr", -// "method": "HTTP/POST", // HTTP/POST | IFRAME/RPC | HTTP/RPC -// "identity": { -// "address": "0x______", -// "keyId": 0, -// }, -// "data": {}, // included in body of pre-authz request -// "params": {}, // included as query params on endpoint url -// } -export function normalizePreAuthz(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - return { - ...SERVICE_PRAGMA, - type: service.type, - uid: service.id, - endpoint: service.endpoint, - method: service.method, - identity: { - ...IDENTITY_PRAGMA, - address: withPrefix(service.addr), - keyId: service.keyId, - }, - params: service.params, - data: service.data, - } - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/pre-authz.ts b/packages/fcl-core/src/normalizers/service/pre-authz.ts new file mode 100644 index 000000000..54477f304 --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/pre-authz.ts @@ -0,0 +1,54 @@ +import {withPrefix} from "@onflow/util-address" +import {IDENTITY_PRAGMA, SERVICE_PRAGMA} from "./__vsn" +import {AuthzService} from "./authz" + +/** + * @description Normalizes a pre-authz service to ensure compatibility with FCL service format + * + * @param service The pre-authz service to normalize + * @returns The normalized pre-authz service or null + * + * @example + * const service = normalizePreAuthz({ + * f_type: "service", + * f_vsn: "1.0.0", + * type: "pre-authz", + * uid: "uniqueDedupeKey", + * endpoint: "https://rawr", + * method: "HTTP/POST", // HTTP/POST | IFRAME/RPC | HTTP/RPC + * identity: { + * address: "0x______", + * keyId: 0, + * }, + * }) + */ +export function normalizePreAuthz( + service: AuthzService | null +): AuthzService | null { + if (service == null) return null + + if (!service["f_vsn"]) { + return { + ...SERVICE_PRAGMA, + type: service.type, + uid: service.id, + endpoint: service.endpoint, + method: service.method, + identity: { + ...IDENTITY_PRAGMA, + address: withPrefix(service.addr!), + keyId: service.keyId, + }, + params: service.params, + data: service.data, + } as AuthzService + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/normalizers/service/service.js b/packages/fcl-core/src/normalizers/service/service.js deleted file mode 100644 index 0d0673106..000000000 --- a/packages/fcl-core/src/normalizers/service/service.js +++ /dev/null @@ -1,43 +0,0 @@ -import {normalizeAuthn} from "./authn" -import {normalizeAuthz} from "./authz" -import {normalizePreAuthz} from "./pre-authz" -import {normalizeFrame} from "./frame" -import {normalizeBackChannelRpc} from "./back-channel-rpc" -import {normalizeOpenId} from "./open-id" -import {normalizeUserSignature} from "./user-signature" -import {normalizeLocalView} from "./local-view" -import {normalizeAccountProof} from "./account-proof" -import {normalizeAuthnRefresh} from "./authn-refresh" - -export function normalizeServices(services, data) { - return services - .map(service => normalizeService(service, data)) - .filter(Boolean) -} - -const serviceNormalizers = { - "back-channel-rpc": normalizeBackChannelRpc, - "pre-authz": normalizePreAuthz, - authz: normalizeAuthz, - authn: normalizeAuthn, - frame: normalizeFrame, - "open-id": normalizeOpenId, - "user-signature": normalizeUserSignature, - "local-view": normalizeLocalView, - "account-proof": normalizeAccountProof, - "authn-refresh": normalizeAuthnRefresh, -} - -export function normalizeService(service, data) { - try { - var normalized = serviceNormalizers[service.type](service, data) - return normalized - } catch (error) { - console.error( - `Unrecognized FCL Service Type [${service.type}]`, - service, - error - ) - return service - } -} diff --git a/packages/fcl-core/src/normalizers/service/service.ts b/packages/fcl-core/src/normalizers/service/service.ts new file mode 100644 index 000000000..2ad6ea284 --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/service.ts @@ -0,0 +1,88 @@ +import {normalizeAuthn} from "./authn" +import {normalizeAuthz} from "./authz" +import {normalizePreAuthz} from "./pre-authz" +import {normalizeFrame} from "./frame" +import {normalizeBackChannelRpc} from "./back-channel-rpc" +import {normalizeOpenId} from "./open-id" +import {normalizeUserSignature} from "./user-signature" +import {normalizeLocalView} from "./local-view" +import {normalizeAccountProof} from "./account-proof" +import {normalizeAuthnRefresh} from "./authn-refresh" +import type {Service} from "@onflow/typedefs" + +/** + * @description Normalizes an array of services by applying type-specific normalization to each service. + * This function processes multiple services in batch, applying the appropriate normalizer based on + * each service's type, and filters out any services that fail normalization. + * + * @param services Array of services to normalize + * @param data Optional additional data to pass to individual service normalizers + * @returns Array of normalized services with invalid services filtered out + * + * @example + * // Normalize multiple services from wallet discovery + * const rawServices = [ + * { type: "authn", endpoint: "https://wallet.com/authn", ... }, + * { type: "authz", endpoint: "https://wallet.com/authz", ... }, + * { type: "user-signature", endpoint: "https://wallet.com/sign", ... } + * ] + * + * const normalizedServices = normalizeServices(rawServices) + * console.log("Normalized services:", normalizedServices) + */ +export function normalizeServices(services: Service[], data?: any): Service[] { + return services + .map(service => normalizeService(service, data)) + .filter(Boolean) +} + +const serviceNormalizers: Record< + string, + (service: Service, data?: any) => any +> = { + "back-channel-rpc": normalizeBackChannelRpc, + "pre-authz": normalizePreAuthz, + authz: normalizeAuthz, + authn: normalizeAuthn, + frame: normalizeFrame, + "open-id": normalizeOpenId, + "user-signature": normalizeUserSignature, + "local-view": normalizeLocalView, + "account-proof": normalizeAccountProof, + "authn-refresh": normalizeAuthnRefresh, +} + +/** + * @description Normalizes a single service by applying the appropriate type-specific normalizer. + * This function looks up the correct normalizer based on the service type and applies it to + * ensure the service conforms to expected formats and contains required fields. + * + * @param service The service object to normalize + * @param data Optional additional data to pass to the service normalizer + * @returns The normalized service object + * + * @example + * // Normalize an authentication service + * const rawService = { + * type: "authn", + * endpoint: "https://wallet.example.com/authn", + * method: "HTTP/POST", + * // ... other service properties + * } + * + * const normalized = normalizeService(rawService) + * console.log("Normalized service:", normalized) + */ +export function normalizeService(service: Service, data?: any): Service { + try { + const normalized = serviceNormalizers[service.type](service, data) + return normalized + } catch (error) { + console.error( + `Unrecognized FCL Service Type [${service.type}]`, + service, + error + ) + return service + } +} diff --git a/packages/fcl-core/src/normalizers/service/user-signature.js b/packages/fcl-core/src/normalizers/service/user-signature.js deleted file mode 100644 index a0ab7aaba..000000000 --- a/packages/fcl-core/src/normalizers/service/user-signature.js +++ /dev/null @@ -1,26 +0,0 @@ -// { -// "f_type": "Service", -// "f_vsn": "1.0.0", -// "type": "user-signature", -// "uid": "uniqueDedupeKey", -// "endpoint": "https://rawr", -// "method": "IFRAME/RPC", // HTTP/POST | IFRAME/RPC | HTTP/RPC -// "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // wallets internal id for the user -// "data": {}, // included in body of user-signature request -// "params": {}, // included as query params on endpoint url -// } -export function normalizeUserSignature(service) { - if (service == null) return null - - if (!service["f_vsn"]) { - throw new Error("Invalid user-signature service") - } - - switch (service["f_vsn"]) { - case "1.0.0": - return service - - default: - return null - } -} diff --git a/packages/fcl-core/src/normalizers/service/user-signature.ts b/packages/fcl-core/src/normalizers/service/user-signature.ts new file mode 100644 index 000000000..49f007b0e --- /dev/null +++ b/packages/fcl-core/src/normalizers/service/user-signature.ts @@ -0,0 +1,38 @@ +import type {Service} from "@onflow/typedefs" + +/** + * @description Normalizes a user-signature service to ensure compatibility with FCL service format + * + * @param service The user-signature service to normalize + * @returns The normalized user-signature service or null + * + * @example + * const service = { + * "f_type": "Service", + * "f_vsn": "1.0.0", + * "type": "user-signature", + * "uid": "uniqueDedupeKey", + * "endpoint": "https://rawr", + * "method": "IFRAME/RPC", // HTTP/POST | IFRAME/RPC | HTTP/RPC + * "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // wallets internal id for the user + * "data": {}, // included in body of user-signature request + * "params": {}, // included as query params on endpoint url + * } + */ +export function normalizeUserSignature( + service: Service | null +): Service | null { + if (service == null) return null + + if (!service["f_vsn"]) { + throw new Error("Invalid user-signature service") + } + + switch (service["f_vsn"]) { + case "1.0.0": + return service + + default: + return null + } +} diff --git a/packages/fcl-core/src/serialize/index.js b/packages/fcl-core/src/serialize/index.js deleted file mode 100644 index 5ff969542..000000000 --- a/packages/fcl-core/src/serialize/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import {interaction, pipe} from "@onflow/sdk" -import {resolve as defaultResolve} from "@onflow/sdk" -import {config, createSignableVoucher} from "@onflow/sdk" - -export const serialize = async (args = [], opts = {}) => { - const resolveFunction = await config.first( - ["sdk.resolve"], - opts.resolve || defaultResolve - ) - - if (Array.isArray(args)) args = await pipe(interaction(), args) - - return JSON.stringify( - createSignableVoucher(await resolveFunction(args)), - null, - 2 - ) -} diff --git a/packages/fcl-core/src/serialize/index.ts b/packages/fcl-core/src/serialize/index.ts new file mode 100644 index 000000000..0913b7246 --- /dev/null +++ b/packages/fcl-core/src/serialize/index.ts @@ -0,0 +1,65 @@ +import { + config, + createSignableVoucher, + resolve as defaultResolve, + interaction, + InteractionBuilderFn, + pipe, +} from "@onflow/sdk" +import {Interaction} from "@onflow/typedefs" + +export interface SerializeOptions { + resolve?: InteractionBuilderFn +} + +/** + * @description Serializes a Flow transaction or script to a JSON-formatted signable voucher that can be + * used for offline signing or inspection. This is useful for creating signable transactions that can be + * signed by external wallets or hardware devices. + * + * @param args Array of interaction builder functions or a pre-built interaction object. Builder functions are typically from @onflow/sdk such as + * transaction(), script(), args(), proposer(), etc. + * @param opts Optional configuration object + * @param opts.resolve Custom resolve function to use instead of the default + * + * @returns A JSON string representation of the signable voucher that contains all + * the transaction details needed for signing + * + * @example + * // Serialize a simple transaction + * import * as fcl from "@onflow/fcl" + * + * const voucher = await fcl.serialize([ + * fcl.transaction` + * transaction(amount: UFix64, to: Address) { + * prepare(signer: AuthAccount) { + * // Transaction logic here + * } + * } + * `, + * fcl.args([ + * fcl.arg("10.0", fcl.t.UFix64), + * fcl.arg("0x01", fcl.t.Address) + * ]), + * fcl.proposer(authz), + * fcl.payer(authz), + * fcl.authorizations([authz]) + * ]) + */ +export const serialize = async ( + args: (InteractionBuilderFn | false)[] | Interaction, + opts: SerializeOptions = {} +) => { + const resolveFunction = await config.first( + ["sdk.resolve"], + opts.resolve || defaultResolve + ) + + if (Array.isArray(args)) args = await pipe(interaction(), args) + + return JSON.stringify( + createSignableVoucher(await resolveFunction(args)), + null, + 2 + ) +} diff --git a/packages/fcl-core/src/transaction/transaction-error.ts b/packages/fcl-core/src/transaction/transaction-error.ts index 4fca209ab..105df917f 100644 --- a/packages/fcl-core/src/transaction/transaction-error.ts +++ b/packages/fcl-core/src/transaction/transaction-error.ts @@ -2,6 +2,17 @@ import {FvmErrorCode} from "@onflow/typedefs" const ERROR_CODE_REGEX = /\[Error Code: (\d+)\]/ +// Reverse mapping from error codes to error type names for better lookup +const ERROR_CODE_TO_TYPE: Record = Object.entries( + FvmErrorCode +).reduce( + (acc, [key, value]) => { + acc[value as number] = key + return acc + }, + {} as Record +) + export class TransactionError extends Error { public code: FvmErrorCode public type: string @@ -9,16 +20,16 @@ export class TransactionError extends Error { private constructor(message: string, code: FvmErrorCode) { super(message) this.code = code - this.type = FvmErrorCode[code] + this.type = ERROR_CODE_TO_TYPE[code] || "UNKNOWN_ERROR" } static fromErrorMessage(errorMessage: string): TransactionError { const match = errorMessage.match(ERROR_CODE_REGEX) - const code = match ? parseInt(match[1], 10) : undefined + const code = match ? (parseInt(match[1], 10) as FvmErrorCode) : undefined return new TransactionError( errorMessage, - code || FvmErrorCode.UNKNOWN_ERROR + code || (FvmErrorCode.UNKNOWN_ERROR as FvmErrorCode) ) } } diff --git a/packages/fcl-core/src/transaction/transaction.ts b/packages/fcl-core/src/transaction/transaction.ts index 8ff6acce8..ccff023f8 100644 --- a/packages/fcl-core/src/transaction/transaction.ts +++ b/packages/fcl-core/src/transaction/transaction.ts @@ -30,13 +30,76 @@ const FLOW_EMULATOR = "local" const registry = new Map>() /** - * Provides methods for interacting with a transaction + * @description Creates a transaction monitor that provides methods for tracking and subscribing to + * transaction status updates on the Flow blockchain. This function returns an object with methods + * to get snapshots, subscribe to status changes, and wait for specific transaction states. * - * @param transactionId - The transaction ID - * @param opts - Optional parameters - * @param opts.pollRate - Polling rate in milliseconds - * @param opts.txNotFoundTimeout - Timeout in milliseconds for ignoring transaction not found errors (do not modify unless you know what you are doing) - * @throws {Error} If transactionId is not a 64 byte hash string + * @param transactionId The 64-character hex transaction ID to monitor. Must be a valid + * Flow transaction hash (64 bytes represented as hex string). + * @param opts Optional configuration parameters + * @param opts.pollRate Polling rate in milliseconds when using legacy polling fallback + * @param opts.txNotFoundTimeout Timeout in milliseconds for ignoring transaction + * not found errors during initial transaction propagation (do not modify unless you know what you are doing) + * + * @returns Transaction monitor object with methods for tracking transaction status + * + * @throws If transactionId is not a valid 64-byte hash string + * + * @example + * // Basic transaction monitoring + * import * as fcl from "@onflow/fcl" + * + * const txId = await fcl.mutate({ + * cadence: ` + * transaction { + * execute { log("Hello, World!") } + * } + * ` + * }) + * + * // Get current status + * const status = await fcl.tx(txId).snapshot() + * console.log("Current status:", status.status) + * + * // Subscribe to all status changes + * const unsubscribe = fcl.tx(txId).subscribe((status) => { + * console.log("Status update:", status.status) + * if (status.status === fcl.transaction.isSealed) { + * console.log("Transaction sealed!") + * console.log("Events:", status.events) + * } + * }) + * // Clean up subscription when done + * setTimeout(() => unsubscribe(), 60000) + * + * // Wait for specific transaction states + * try { + * // Wait for finalization (consensus reached) + * const finalizedStatus = await fcl.tx(txId).onceFinalized() + * console.log("Transaction finalized") + * + * // Wait for execution (transaction executed) + * const executedStatus = await fcl.tx(txId).onceExecuted() + * console.log("Transaction executed") + * + * // Wait for sealing (transaction sealed in block) + * const sealedStatus = await fcl.tx(txId).onceSealed() + * console.log("Transaction sealed:", sealedStatus.events) + * } catch (error) { + * console.error("Transaction failed:", error.message) + * } + * + * // Handle transaction errors + * fcl.tx(txId).subscribe( + * (status) => { + * if (status.statusCode === 1) { + * console.error("Transaction error:", status.errorMessage) + * } + * }, + * (error) => { + * console.error("Subscription error:", error) + * } + * ) */ export function transaction( transactionId: string, @@ -125,7 +188,7 @@ transaction.isSealed = isSealed transaction.isExpired = isExpired /** - * Creates an observable for a transaction + * @description Creates an observable for a transaction */ function createObservable( txId: string, diff --git a/packages/fcl-core/src/transaction/utils.ts b/packages/fcl-core/src/transaction/utils.ts index 3cbe31cd6..f6255e7eb 100644 --- a/packages/fcl-core/src/transaction/utils.ts +++ b/packages/fcl-core/src/transaction/utils.ts @@ -1,12 +1,115 @@ import {TransactionStatus} from "@onflow/typedefs" +/** + * @description Checks if a transaction has expired based on its status code. + * A transaction is considered expired when its status equals 5. + * + * @param tx The transaction status object to check + * @returns True if the transaction has expired, false otherwise + * + * @example + * // Check if a transaction has expired + * const txStatus = await fcl.tx(transactionId).snapshot() + * if (isExpired(txStatus)) { + * console.log("Transaction has expired") + * } + */ export const isExpired = (tx: TransactionStatus) => tx.status === 5 + +/** + * @description Checks if a transaction has been sealed. A transaction is sealed when it has been + * included in a block and finalized on the blockchain (status >= 4). + * + * @param tx The transaction status object to check + * @returns True if the transaction is sealed, false otherwise + * + * @example + * // Wait for transaction to be sealed + * const txStatus = await fcl.tx(transactionId).snapshot() + * if (isSealed(txStatus)) { + * console.log("Transaction is sealed and finalized") + * } + */ export const isSealed = (tx: TransactionStatus) => tx.status >= 4 + +/** + * @description Checks if a transaction has been executed. A transaction is executed when it has + * been processed by the blockchain network (status >= 3). + * + * @param tx The transaction status object to check + * @returns True if the transaction has been executed, false otherwise + * + * @example + * // Check if transaction has been executed + * const txStatus = await fcl.tx(transactionId).snapshot() + * if (isExecuted(txStatus)) { + * console.log("Transaction has been executed") + * } + */ export const isExecuted = (tx: TransactionStatus) => tx.status >= 3 + +/** + * @description Checks if a transaction has been finalized. A transaction is finalized when it has + * been included in a block (status >= 2). + * + * @param tx The transaction status object to check + * @returns True if the transaction has been finalized, false otherwise + * + * @example + * // Check if transaction has been finalized + * const txStatus = await fcl.tx(transactionId).snapshot() + * if (isFinalized(txStatus)) { + * console.log("Transaction has been finalized") + * } + */ export const isFinalized = (tx: TransactionStatus) => tx.status >= 2 + +/** + * @description Checks if a transaction is pending. A transaction is pending when it has been + * submitted to the network but not yet processed (status >= 1). + * + * @param tx The transaction status object to check + * @returns True if the transaction is pending, false otherwise + * + * @example + * // Check if transaction is still pending + * const txStatus = await fcl.tx(transactionId).snapshot() + * if (isPending(txStatus)) { + * console.log("Transaction is still pending") + * } + */ export const isPending = (tx: TransactionStatus) => tx.status >= 1 + +/** + * @description Checks if a transaction status is unknown. A transaction has unknown status when + * it hasn't been processed yet or there's no information available (status >= 0). + * + * @param tx The transaction status object to check + * @returns True if the transaction status is unknown, false otherwise + * + * @example + * // Check if transaction status is unknown + * const txStatus = await fcl.tx(transactionId).snapshot() + * if (isUnknown(txStatus)) { + * console.log("Transaction status is unknown") + * } + */ export const isUnknown = (tx: TransactionStatus) => tx.status >= 0 +/** + * @description Performs a deep equality comparison between two values. This function recursively + * compares all properties of objects and arrays to determine if they are equal. + * + * @param a First value to compare + * @param b Second value to compare + * @returns True if the values are deeply equal, false otherwise + * + * @example + * // Compare two objects + * const obj1 = { name: "Flow", version: "1.0" } + * const obj2 = { name: "Flow", version: "1.0" } + * console.log(deepEqual(obj1, obj2)) // true + */ export const deepEqual = (a: any, b: any): boolean => { if (a === b) return true if (typeof a !== "object" || typeof b !== "object") return false @@ -15,10 +118,37 @@ export const deepEqual = (a: any, b: any): boolean => { return true } +/** + * @description Checks if two values are different by performing a deep equality comparison. + * This is the inverse of the deepEqual function. + * + * @param a First value to compare + * @param b Second value to compare + * @returns True if the values are different, false if they are equal + * + * @example + * // Check if objects are different + * const obj1 = { name: "Flow", version: "1.0" } + * const obj2 = { name: "Flow", version: "2.0" } + * console.log(isDiff(obj1, obj2)) // true + */ export const isDiff = (a: any, b: any): boolean => { return !deepEqual(a, b) } +/** + * @description Extracts a transaction ID from either a string or an object containing a transactionId property. + * This utility function handles both formats and ensures a valid transaction ID is returned. + * + * @param transactionId Either a transaction ID string or an object with a transactionId property + * @returns The transaction ID as a string + * @throws If transactionId is null, undefined, or invalid + * + * @example + * // Extract from string + * const txId = scoped("abc123def456") + * console.log(txId) // "abc123def456" + */ export const scoped = ( transactionId: | string diff --git a/packages/fcl-core/src/utils/chain-id/chain-id-watcher.js b/packages/fcl-core/src/utils/chain-id/chain-id-watcher.js deleted file mode 100644 index 5ed3298d0..000000000 --- a/packages/fcl-core/src/utils/chain-id/chain-id-watcher.js +++ /dev/null @@ -1,18 +0,0 @@ -import {config} from "@onflow/config" -import {getChainId} from "./get-chain-id" - -/** - * @description - * Watches the config for changes to access node and updates the chain id accordingly - * - * @returns {Function} A function that unsubscribes the listener - * - */ -export function watchForChainIdChanges() { - return config.subscribe(() => { - // Call getChainId to update the chainId cache if access node has changed - getChainId({ - enableRequestLogging: false, - }).catch(() => {}) - }) -} diff --git a/packages/fcl-core/src/utils/chain-id/chain-id-watcher.test.js b/packages/fcl-core/src/utils/chain-id/chain-id-watcher.test.ts similarity index 95% rename from packages/fcl-core/src/utils/chain-id/chain-id-watcher.test.js rename to packages/fcl-core/src/utils/chain-id/chain-id-watcher.test.ts index 160c3a870..24371fcf1 100644 --- a/packages/fcl-core/src/utils/chain-id/chain-id-watcher.test.js +++ b/packages/fcl-core/src/utils/chain-id/chain-id-watcher.test.ts @@ -16,7 +16,7 @@ describe("chain-id-watcher", () => { async () => { // Mock the setChainIdDefault function const spy = jest.spyOn(chainIdUtils, "getChainId") - spy.mockImplementation(async () => {}) + spy.mockImplementation((async () => {}) as any) // Start watching for changes unsubscribe = watchForChainIdChanges() @@ -34,7 +34,7 @@ describe("chain-id-watcher", () => { await config.overload({}, async () => { // Mock the setChainIdDefault function const spy = jest.spyOn(chainIdUtils, "getChainId") - spy.mockImplementation(async () => {}) + spy.mockImplementation((async () => {}) as any) // Start watching for changes unsubscribe = watchForChainIdChanges() diff --git a/packages/fcl-core/src/utils/chain-id/chain-id-watcher.ts b/packages/fcl-core/src/utils/chain-id/chain-id-watcher.ts new file mode 100644 index 000000000..de633ec85 --- /dev/null +++ b/packages/fcl-core/src/utils/chain-id/chain-id-watcher.ts @@ -0,0 +1,27 @@ +import {config} from "@onflow/config" +import {getChainId} from "./get-chain-id" + +/** + * @description Watches the FCL configuration for changes to the access node and automatically updates + * the chain ID cache accordingly. This ensures that chain ID information stays current when the + * access node configuration changes, preventing stale chain ID data from being used. + * + * @returns A function that can be called to unsubscribe the configuration listener + * + * @example + * // Start watching for chain ID changes + * import * as fcl from "@onflow/fcl" + * + * const unsubscribe = fcl.watchForChainIdChanges() + * + * // Later, when you want to stop watching + * unsubscribe() + */ +export function watchForChainIdChanges(): () => void { + return config.subscribe(() => { + // Call getChainId to update the chainId cache if access node has changed + getChainId({ + enableRequestLogging: false, + }).catch(() => {}) + }) +} diff --git a/packages/fcl-core/src/utils/chain-id/fetch-chain-id.js b/packages/fcl-core/src/utils/chain-id/fetch-chain-id.js deleted file mode 100644 index 34b074c71..000000000 --- a/packages/fcl-core/src/utils/chain-id/fetch-chain-id.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as sdk from "@onflow/sdk" - -export async function fetchChainId(opts = {}) { - const response = await sdk - .send([sdk.getNetworkParameters()], opts) - .then(sdk.decode) - return response.chainId -} diff --git a/packages/fcl-core/src/utils/chain-id/fetch-chain-id.ts b/packages/fcl-core/src/utils/chain-id/fetch-chain-id.ts new file mode 100644 index 000000000..f2d2887be --- /dev/null +++ b/packages/fcl-core/src/utils/chain-id/fetch-chain-id.ts @@ -0,0 +1,23 @@ +import * as sdk from "@onflow/sdk" + +/** + * @description Fetches the chain ID from the Flow network by querying the network parameters. + * The chain ID is a unique identifier for the specific Flow network (mainnet, testnet, etc.) + * and is essential for ensuring transactions are executed on the correct network. + * + * @param opts Optional configuration object that can contain network access settings and other parameters + * @returns Promise that resolves to the chain ID string (e.g., "flow-mainnet", "flow-testnet") + * + * @example + * // Fetch chain ID from the configured network + * const chainId = await fetchChainId() + * console.log(chainId) // "flow-mainnet" or "flow-testnet" + */ +export async function fetchChainId( + opts: Record = {} +): Promise { + const response = await sdk + .send([sdk.getNetworkParameters()], opts) + .then(sdk.decode) + return response.chainId +} diff --git a/packages/fcl-core/src/utils/chain-id/get-chain-id.test.js b/packages/fcl-core/src/utils/chain-id/get-chain-id.test.ts similarity index 100% rename from packages/fcl-core/src/utils/chain-id/get-chain-id.test.js rename to packages/fcl-core/src/utils/chain-id/get-chain-id.test.ts diff --git a/packages/fcl-core/src/utils/chain-id/get-chain-id.js b/packages/fcl-core/src/utils/chain-id/get-chain-id.ts similarity index 62% rename from packages/fcl-core/src/utils/chain-id/get-chain-id.js rename to packages/fcl-core/src/utils/chain-id/get-chain-id.ts index 41fa78e93..1965fd5ec 100644 --- a/packages/fcl-core/src/utils/chain-id/get-chain-id.js +++ b/packages/fcl-core/src/utils/chain-id/get-chain-id.ts @@ -4,26 +4,39 @@ import {log} from "@onflow/util-logger" // Cache of chainId promises for each access node value // key: access node, value: chainId promise -let chainIdCache = {} +let chainIdCache: Record | null> = {} -let hasWarnedFlowNetwork = false -let hasWarnedEnv = false +let hasWarnedFlowNetwork: boolean = false +let hasWarnedEnv: boolean = false + +export interface GetChainIdOptions { + node?: unknown + enableRequestLogging?: boolean + [key: string]: any +} /** * @description * Gets the chain ID if its set, otherwise gets the chain ID from the access node * - * @param {object} opts - Optional parameters - * @returns {Promise} The chain ID of the access node - * @throws {Error} If the chain ID is not found + * @param opts Optional configuration parameters + * @param opts.node Override the access node URL for this request instead of using the configured one + * @param opts.enableRequestLogging Enable logging for the chain ID request + * @returns Promise that resolves to the chain ID string (e.g., "mainnet", "testnet", "local") + * @throws If the chain ID cannot be determined from configuration or access node * * @example - * // returns "testnet" - * getChainId() + * // Get chain ID using configured access node + * import * as fcl from "@onflow/fcl" + * + * const chainId = await fcl.getChainId() + * console.log("Connected to:", chainId) // "testnet" or "mainnet" */ -export async function getChainId(opts = {}) { - let flowNetworkCfg = await config.get("flow.network") - let envCfg = await config.get("env") +export async function getChainId( + opts: GetChainIdOptions = {} +): Promise { + let flowNetworkCfg: string | null = await config.get("flow.network") + let envCfg: string | null = await config.get("env") /* TODO: Add deprecation warning for flow.network config key @@ -81,27 +94,29 @@ export async function getChainId(opts = {}) { } // Try using cached chainId first if it exists and access node is the same - if (chainIdCache[accessNode]) { + if (chainIdCache[accessNode as string]) { try { - return await chainIdCache[accessNode] + return await chainIdCache[accessNode as string]! } catch {} } // If no cached chainId, value is stale, or last attempt failed, try getting chainId from access node // Check if another getChainId() call has already started a new promise, if not, start a new one // There may have been concurrent calls to getChainId() while the first call was waiting for the response - if (!chainIdCache[accessNode]) { - chainIdCache[accessNode] = fetchChainId(opts).catch(error => { - // If there was an error, reset the promise so that the next call will try again - chainIdCache[accessNode] = null - throw error - }) + if (!chainIdCache[accessNode as string]) { + chainIdCache[accessNode as string] = fetchChainId(opts).catch( + (error: Error) => { + // If there was an error, reset the promise so that the next call will try again + chainIdCache[accessNode as string] = null + throw error + } + ) } // Use newly created promise try { - return await chainIdCache[accessNode] - } catch (e) { + return await chainIdCache[accessNode as string]! + } catch (e: any) { // Fall back to deprecated flow.network and env config keys // This probably should have been done before trying to fetch the chainId from the access node // However, this was the behaviour with the initial implementation of getChainId() @@ -118,9 +133,20 @@ export async function getChainId(opts = {}) { } /** - * @description - * Clears the chainId cache, useful for testing + * @description Clears the internal chain ID cache used by getChainId function. This is primarily useful + * for testing scenarios where you need to reset the cached chain ID values, or when switching between + * different access nodes and want to ensure fresh chain ID fetching. + * + * @example + * // Clear cache during testing + * import * as fcl from "@onflow/fcl" + * + * // Clear cache + * fcl.clearChainIdCache() + * + * // Now getChainId will fetch fresh data + * const chainId = await fcl.getChainId() */ -export function clearChainIdCache() { +export function clearChainIdCache(): void { chainIdCache = {} } diff --git a/packages/fcl-core/src/utils/constants.js b/packages/fcl-core/src/utils/constants.ts similarity index 50% rename from packages/fcl-core/src/utils/constants.js rename to packages/fcl-core/src/utils/constants.ts index 6d1792269..3e51bfcf3 100644 --- a/packages/fcl-core/src/utils/constants.js +++ b/packages/fcl-core/src/utils/constants.ts @@ -1,7 +1,7 @@ -export const FCL_REDIRECT_URL_PARAM_NAME = "fcl_redirect_url" -export const FCL_RESPONSE_PARAM_NAME = "fclResponseJson" +export const FCL_REDIRECT_URL_PARAM_NAME: string = "fcl_redirect_url" +export const FCL_RESPONSE_PARAM_NAME: string = "fclResponseJson" -export const CORE_STRATEGIES = { +export const CORE_STRATEGIES: Record = { "HTTP/RPC": "HTTP/RPC", "HTTP/POST": "HTTP/POST", "IFRAME/RPC": "IFRAME/RPC", diff --git a/packages/fcl-core/src/utils/index.js b/packages/fcl-core/src/utils/index.js deleted file mode 100644 index ba2ec0896..000000000 --- a/packages/fcl-core/src/utils/index.js +++ /dev/null @@ -1,26 +0,0 @@ -export {getChainId} from "./chain-id/get-chain-id" -export {watchForChainIdChanges} from "./chain-id/chain-id-watcher" - -export function isAndroid() { - return ( - typeof navigator !== "undefined" && /android/i.test(navigator.userAgent) - ) -} - -export function isSmallIOS() { - return ( - typeof navigator !== "undefined" && /iPhone|iPod/.test(navigator.userAgent) - ) -} - -export function isLargeIOS() { - return typeof navigator !== "undefined" && /iPad/.test(navigator.userAgent) -} - -export function isIOS() { - return isSmallIOS() || isLargeIOS() -} - -export function isMobile() { - return isAndroid() || isIOS() -} diff --git a/packages/fcl-core/src/utils/index.ts b/packages/fcl-core/src/utils/index.ts new file mode 100644 index 000000000..5eb7f1727 --- /dev/null +++ b/packages/fcl-core/src/utils/index.ts @@ -0,0 +1,98 @@ +export {getChainId} from "./chain-id/get-chain-id" +export {watchForChainIdChanges} from "./chain-id/chain-id-watcher" + +/** + * @description Detects if the current environment is running on an Android device by checking the user agent string. + * + * @returns True if running on Android, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl" + * + * if (fcl.isAndroid()) { + * console.log("Running on Android device") + * // Show Android-specific UI or behavior + * } + */ +export function isAndroid(): boolean { + return ( + typeof navigator !== "undefined" && /android/i.test(navigator.userAgent) + ) +} + +/** + * @description Detects if the current environment is running on a small iOS device (iPhone or iPod Touch) + * by checking the user agent string. + * + * @returns True if running on iPhone or iPod Touch, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl" + * + * if (fcl.isSmallIOS()) { + * console.log("Running on iPhone or iPod") + * // Adjust UI for smaller screen + * } + */ +export function isSmallIOS(): boolean { + return ( + typeof navigator !== "undefined" && /iPhone|iPod/.test(navigator.userAgent) + ) +} + +/** + * @description Detects if the current environment is running on a large iOS device (iPad) + * by checking the user agent string. + * + * @returns True if running on iPad, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl" + * + * if (fcl.isLargeIOS()) { + * console.log("Running on iPad") + * // Show tablet-optimized layout + * } + */ +export function isLargeIOS(): boolean { + return typeof navigator !== "undefined" && /iPad/.test(navigator.userAgent) +} + +/** + * @description Detects if the current environment is running on any iOS device (iPhone, iPod, or iPad). + * This is a convenience function that combines isSmallIOS() and isLargeIOS(). + * + * @returns True if running on any iOS device, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl" + * + * if (fcl.isIOS()) { + * console.log("Running on iOS device") + * // Apply iOS-specific styles or behaviors + * } + */ +export function isIOS(): boolean { + return isSmallIOS() || isLargeIOS() +} + +/** + * @description Detects if the current environment is running on a mobile device (Android or iOS). + * This is useful for providing mobile-optimized experiences or enabling mobile-specific features. + * + * @returns True if running on a mobile device, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl" + * + * if (fcl.isMobile()) { + * console.log("Running on mobile device") + * // Enable touch gestures, mobile wallet connections, etc. + * } else { + * console.log("Running on desktop") + * // Show desktop wallet options + * } + */ +export function isMobile(): boolean { + return isAndroid() || isIOS() +} diff --git a/packages/fcl-core/src/utils/is-react-native.js b/packages/fcl-core/src/utils/is-react-native.js deleted file mode 100644 index 5c00c34d6..000000000 --- a/packages/fcl-core/src/utils/is-react-native.js +++ /dev/null @@ -1,9 +0,0 @@ -let _isReactNative = false - -export function isReactNative() { - return _isReactNative -} - -export function setIsReactNative(value) { - _isReactNative = value -} diff --git a/packages/fcl-core/src/utils/is-react-native.ts b/packages/fcl-core/src/utils/is-react-native.ts new file mode 100644 index 000000000..19ed8cb4b --- /dev/null +++ b/packages/fcl-core/src/utils/is-react-native.ts @@ -0,0 +1,49 @@ +let _isReactNative: boolean = false + +/** + * @description Checks if the current environment is React Native. This function returns a boolean + * indicating whether FCL is running in a React Native environment rather than a browser or Node.js. + * This is useful for platform-specific functionality and enabling React Native-specific features. + * + * @returns True if running in React Native environment, false otherwise + * + * @example + * // Check if running in React Native + * import * as fcl from "@onflow/fcl" + * + * if (fcl.isReactNative()) { + * console.log("Running in React Native") + * // Use React Native specific wallet integrations + * // Enable deep linking for wallet connections + * } else { + * console.log("Running in browser or Node.js") + * // Use web-based wallet integrations + * } + */ +export function isReactNative(): boolean { + return _isReactNative +} + +/** + * @description Sets the React Native environment flag for FCL. This function should be called during + * initialization of React Native applications to inform FCL that it's running in a React Native + * environment. This enables React Native-specific behaviors and optimizations. + * + * @param value True to indicate React Native environment, false otherwise + * + * @example + * // Set React Native flag during app initialization + * import * as fcl from "@onflow/fcl" + * + * // In your React Native app's entry point (e.g., App.js) + * fcl.setIsReactNative(true) + * + * // Configure FCL for React Native + * fcl.config({ + * "accessNode.api": "https://rest-testnet.onflow.org", + * "discovery.wallet": "https://fcl-discovery.onflow.org/api/testnet/authn" + * }) + */ +export function setIsReactNative(value: boolean): void { + _isReactNative = value +} diff --git a/packages/fcl-core/src/utils/is.js b/packages/fcl-core/src/utils/is.js deleted file mode 100644 index 3d18b0c33..000000000 --- a/packages/fcl-core/src/utils/is.js +++ /dev/null @@ -1,7 +0,0 @@ -const is = type => d => typeof d === type - -export const isRequired = d => d != null -export const isObject = is("object") -export const isString = is("string") -export const isFunc = is("function") -export const isNumber = is("number") diff --git a/packages/fcl-core/src/utils/is.ts b/packages/fcl-core/src/utils/is.ts new file mode 100644 index 000000000..0118b5c84 --- /dev/null +++ b/packages/fcl-core/src/utils/is.ts @@ -0,0 +1,93 @@ +const is = + (type: string) => + (d: any): d is T => + typeof d === type + +/** + * @description Checks if a value is required (not null or undefined). This is a type guard that + * ensures the value is not null or undefined, useful for validation and filtering operations. + * + * @param d The value to check for null or undefined + * @returns True if the value is not null or undefined, false otherwise + * + * @example + * // Filter out null/undefined values from an array + * import * as fcl from "@onflow/fcl" + * + * const values = [1, null, "hello", undefined, true] + * const requiredValues = values.filter(fcl.isRequired) + * console.log(requiredValues) // [1, "hello", true] + */ +export const isRequired = (d: any): d is NonNullable => d != null + +/** + * @description Type guard that checks if a value is an object. This is useful for runtime type checking + * and ensuring type safety when working with dynamic data. + * + * @param d The value to check + * @returns True if the value is an object, false otherwise + * + * @example + * // Check if a value is an object + * import * as fcl from "@onflow/fcl" + * + * const obj = { name: "Flow" } + * const notObj = "string" + * console.log(fcl.isObject(obj)) // true + * console.log(fcl.isObject(notObj)) // false + */ +export const isObject = is("object") + +/** + * @description Type guard that checks if a value is a string. Useful for validating input types + * and ensuring type safety in your applications. + * + * @param d The value to check + * @returns True if the value is a string, false otherwise + * + * @example + * // Validate string input + * import * as fcl from "@onflow/fcl" + * + * const text = "Hello, Flow!" + * const notText = 123 + * console.log(fcl.isString(text)) // true + * console.log(fcl.isString(notText)) // false + */ +export const isString = is("string") + +/** + * @description Type guard that checks if a value is a function. This is particularly useful + * when working with callbacks, event handlers, or optional function parameters. + * + * @param d The value to check + * @returns True if the value is a function, false otherwise + * + * @example + * // Check if a callback is provided + * import * as fcl from "@onflow/fcl" + * + * const callback = () => console.log("Hello") + * const notCallback = "string" + * console.log(fcl.isFunc(callback)) // true + * console.log(fcl.isFunc(notCallback)) // false + */ +export const isFunc = is("function") + +/** + * @description Type guard that checks if a value is a number. This includes both integers + * and floating-point numbers, but excludes NaN and Infinity. + * + * @param d The value to check + * @returns True if the value is a number, false otherwise + * + * @example + * // Validate numeric input + * import * as fcl from "@onflow/fcl" + * + * const num = 42 + * const notNum = "42" + * console.log(fcl.isNumber(num)) // true + * console.log(fcl.isNumber(notNum)) // false + */ +export const isNumber = is("number") diff --git a/packages/fcl-core/src/utils/storage.ts b/packages/fcl-core/src/utils/storage.ts index a477becfd..b44a2096f 100644 --- a/packages/fcl-core/src/utils/storage.ts +++ b/packages/fcl-core/src/utils/storage.ts @@ -2,4 +2,5 @@ export type StorageProvider = { can: boolean get: (key: string) => Promise put: (key: string, value: any) => Promise + removeItem: (key: string) => Promise } diff --git a/packages/fcl-core/src/utils/url.js b/packages/fcl-core/src/utils/url.ts similarity index 82% rename from packages/fcl-core/src/utils/url.js rename to packages/fcl-core/src/utils/url.ts index bcbf254fa..a47be966e 100644 --- a/packages/fcl-core/src/utils/url.js +++ b/packages/fcl-core/src/utils/url.ts @@ -13,8 +13,10 @@ import {isReactNative} from "./is-react-native" const _URL = globalThis.URL export class URL extends _URL { - constructor(url, base, ...args) { - super(url, base, ...args) + private _url?: string + + constructor(url: string | URL, base?: string | URL, ...args: any[]) { + super(url, base, ...(args as [])) // Extra check if in React Native if (!isReactNative()) { @@ -22,7 +24,7 @@ export class URL extends _URL { } // Fix trailing slash issue - if (this._url && !url.endsWith("/") && this._url.endsWith("/")) { + if (this._url && !url.toString().endsWith("/") && this._url.endsWith("/")) { this._url = this._url.slice(0, -1) } } diff --git a/packages/fcl-core/src/wallet-utils/CompositeSignature.js b/packages/fcl-core/src/wallet-utils/CompositeSignature.js deleted file mode 100644 index 5450c596e..000000000 --- a/packages/fcl-core/src/wallet-utils/CompositeSignature.js +++ /dev/null @@ -1,18 +0,0 @@ -import {withPrefix} from "@onflow/util-address" -import {COMPOSITE_SIGNATURE_PRAGMA} from "../normalizers/service/__vsn" - -/** - * @description - * Constructs a new CompositeSignature instance. - * - * @param {string} addr - Flow Address - * @param {number} keyId - Key ID - * @param {string} signature - Signature as a hex string - */ -export function CompositeSignature(addr, keyId, signature) { - this.f_type = COMPOSITE_SIGNATURE_PRAGMA.f_type - this.f_vsn = COMPOSITE_SIGNATURE_PRAGMA.f_vsn - this.addr = withPrefix(addr) - this.keyId = Number(keyId) - this.signature = signature -} diff --git a/packages/fcl-core/src/wallet-utils/CompositeSignature.ts b/packages/fcl-core/src/wallet-utils/CompositeSignature.ts new file mode 100644 index 000000000..ae8c8e19d --- /dev/null +++ b/packages/fcl-core/src/wallet-utils/CompositeSignature.ts @@ -0,0 +1,53 @@ +import {withPrefix} from "@onflow/util-address" +import {COMPOSITE_SIGNATURE_PRAGMA} from "../normalizers/service/__vsn" + +/** + * @description Creates a new CompositeSignature instance. CompositeSignature is a standardized + * signature format used in the Flow ecosystem to represent cryptographic signatures along with + * the signing account information. It includes the signature data, the account address, and + * the key ID used for signing. + * + * @param addr Flow account address that created the signature (will be normalized with 0x prefix) + * @param keyId The key ID/index used to create the signature (will be converted to number) + * @param signature The cryptographic signature as a hexadecimal string + * + * @property f_type FCL type identifier, always "CompositeSignature" + * @property f_vsn FCL version identifier for the signature format + * @property addr Flow account address with 0x prefix + * @property keyId Key ID used for signing (as number) + * @property signature Signature data as hex string + * + * @example + * // Create a composite signature for transaction signing + * import { CompositeSignature } from "@onflow/fcl" + * + * const compSig = new CompositeSignature( + * "1234567890abcdef", // will be normalized to "0x1234567890abcdef" + * 0, // key ID + * "abc123def456..." // signature hex string + * ) + * + * console.log(compSig) + * // { + * // f_type: "CompositeSignature", + * // f_vsn: "1.0.0", + * // addr: "0x1234567890abcdef", + * // keyId: 0, + * // signature: "abc123def456..." + * // } + */ +export class CompositeSignature { + f_type: string + f_vsn: string + addr: string + keyId: number + signature: string + + constructor(addr: string, keyId: number | string, signature: string) { + this.f_type = COMPOSITE_SIGNATURE_PRAGMA.f_type + this.f_vsn = COMPOSITE_SIGNATURE_PRAGMA.f_vsn + this.addr = withPrefix(addr) + this.keyId = Number(keyId) + this.signature = signature + } +} diff --git a/packages/fcl-core/src/wallet-utils/encode-account-proof.js b/packages/fcl-core/src/wallet-utils/encode-account-proof.js deleted file mode 100644 index 0b01477a2..000000000 --- a/packages/fcl-core/src/wallet-utils/encode-account-proof.js +++ /dev/null @@ -1,58 +0,0 @@ -import {sansPrefix} from "@onflow/util-address" -import {invariant} from "@onflow/util-invariant" -import {Buffer, encode as rlpEncode} from "@onflow/rlp" - -const rightPaddedHexBuffer = (value, pad) => - Buffer.from(value.padEnd(pad * 2, "0"), "hex") - -const leftPaddedHexBuffer = (value, pad) => - Buffer.from(value.padStart(pad * 2, "0"), "hex") - -const addressBuffer = addr => leftPaddedHexBuffer(addr, 8) - -const nonceBuffer = nonce => Buffer.from(nonce, "hex") - -export const encodeAccountProof = ( - {address, nonce, appIdentifier}, - includeDomainTag = true -) => { - invariant( - address, - "Encode Message For Provable Authn Error: address must be defined" - ) - invariant( - nonce, - "Encode Message For Provable Authn Error: nonce must be defined" - ) - invariant( - appIdentifier, - "Encode Message For Provable Authn Error: appIdentifier must be defined" - ) - - invariant( - nonce.length >= 64, - "Encode Message For Provable Authn Error: nonce must be minimum of 32 bytes" - ) - - const ACCOUNT_PROOF_DOMAIN_TAG = rightPaddedHexBuffer( - Buffer.from("FCL-ACCOUNT-PROOF-V0.0").toString("hex"), - 32 - ) - - if (includeDomainTag) { - return Buffer.concat([ - ACCOUNT_PROOF_DOMAIN_TAG, - rlpEncode([ - appIdentifier, - addressBuffer(sansPrefix(address)), - nonceBuffer(nonce), - ]), - ]).toString("hex") - } - - return rlpEncode([ - appIdentifier, - addressBuffer(sansPrefix(address)), - nonceBuffer(nonce), - ]).toString("hex") -} diff --git a/packages/fcl-core/src/wallet-utils/encode-account-proof.test.js b/packages/fcl-core/src/wallet-utils/encode-account-proof.test.ts similarity index 96% rename from packages/fcl-core/src/wallet-utils/encode-account-proof.test.js rename to packages/fcl-core/src/wallet-utils/encode-account-proof.test.ts index 471adce93..60a5adb08 100644 --- a/packages/fcl-core/src/wallet-utils/encode-account-proof.test.js +++ b/packages/fcl-core/src/wallet-utils/encode-account-proof.test.ts @@ -1,4 +1,4 @@ -import {encodeAccountProof} from "./encode-account-proof.js" +import {encodeAccountProof} from "./encode-account-proof" const address = "0xABC123DEF456" const appIdentifier = "AWESOME-APP-ID" diff --git a/packages/fcl-core/src/wallet-utils/encode-account-proof.ts b/packages/fcl-core/src/wallet-utils/encode-account-proof.ts new file mode 100644 index 000000000..83d2b30ea --- /dev/null +++ b/packages/fcl-core/src/wallet-utils/encode-account-proof.ts @@ -0,0 +1,93 @@ +import {sansPrefix} from "@onflow/util-address" +import {invariant} from "@onflow/util-invariant" +import {Buffer, encode as rlpEncode} from "@onflow/rlp" + +export interface AccountProofData { + address?: string + nonce?: string + appIdentifier?: string +} + +const rightPaddedHexBuffer = (value: string, pad: number): Buffer => + Buffer.from(value.padEnd(pad * 2, "0"), "hex") + +const leftPaddedHexBuffer = (value: string, pad: number): Buffer => + Buffer.from(value.padStart(pad * 2, "0"), "hex") + +const addressBuffer = (addr: string): Buffer => leftPaddedHexBuffer(addr, 8) + +const nonceBuffer = (nonce: string): Buffer => Buffer.from(nonce, "hex") + +/** + * @description Encodes account proof data for cryptographic signing on the Flow blockchain. This function + * creates a standardized message format that combines the application identifier, account address, + * and nonce into a format suitable for cryptographic signing. The encoded message can then be signed + * by the account's private key to create an account proof. + * + * @param data Object containing the account proof components + * @param data.address The Flow account address for which to create the proof + * @param data.nonce A random hexadecimal string (minimum 32 bytes/64 hex chars) to prevent replay attacks + * @param data.appIdentifier A unique identifier for your application to prevent cross-app replay attacks + * @param includeDomainTag Whether to include the FCL domain tag in the encoding + * + * @returns The encoded message as a hexadecimal string ready for signing + * + * @throws If required parameters are missing or invalid, or if nonce is too short + * + * @example + * // Basic account proof encoding + * import { encodeAccountProof } from "@onflow/fcl" + * + * const accountProofData = { + * address: "0x1234567890abcdef", + * nonce: "75f8587e5bd982ec9289c5be1f9426bd12b4c1de9c7a7e4d8c5f9e8b2a7c3f1e9", // 64 hex chars (32 bytes) + * appIdentifier: "MyAwesomeApp" + * } + * + * const encodedMessage = encodeAccountProof(accountProofData) + * console.log("Encoded message:", encodedMessage) + */ +export const encodeAccountProof = ( + {address, nonce, appIdentifier}: AccountProofData, + includeDomainTag: boolean = true +): string => { + invariant( + !!address, + "Encode Message For Provable Authn Error: address must be defined" + ) + invariant( + !!nonce, + "Encode Message For Provable Authn Error: nonce must be defined" + ) + invariant( + !!appIdentifier, + "Encode Message For Provable Authn Error: appIdentifier must be defined" + ) + + invariant( + nonce!.length >= 64, + "Encode Message For Provable Authn Error: nonce must be minimum of 32 bytes" + ) + + const ACCOUNT_PROOF_DOMAIN_TAG = rightPaddedHexBuffer( + Buffer.from("FCL-ACCOUNT-PROOF-V0.0").toString("hex"), + 32 + ) + + if (includeDomainTag) { + return Buffer.concat([ + ACCOUNT_PROOF_DOMAIN_TAG, + rlpEncode([ + appIdentifier, + addressBuffer(sansPrefix(address!)), + nonceBuffer(nonce!), + ]), + ]).toString("hex") + } + + return rlpEncode([ + appIdentifier, + addressBuffer(sansPrefix(address!)), + nonceBuffer(nonce!), + ]).toString("hex") +} diff --git a/packages/fcl-core/src/wallet-utils/index.js b/packages/fcl-core/src/wallet-utils/index.js deleted file mode 100644 index 1757de831..000000000 --- a/packages/fcl-core/src/wallet-utils/index.js +++ /dev/null @@ -1,13 +0,0 @@ -export { - sendMsgToFCL, - ready, - close, - approve, - decline, - redirect, -} from "./send-msg-to-fcl.js" -export {onMessageFromFCL} from "./on-message-from-fcl.js" -export {encodeMessageFromSignable} from "@onflow/sdk" -export {CompositeSignature} from "./CompositeSignature.js" -export {encodeAccountProof} from "./encode-account-proof.js" -export {injectExtService} from "./inject-ext-service.js" diff --git a/packages/fcl-core/src/wallet-utils/index.ts b/packages/fcl-core/src/wallet-utils/index.ts new file mode 100644 index 000000000..a37d0051d --- /dev/null +++ b/packages/fcl-core/src/wallet-utils/index.ts @@ -0,0 +1,13 @@ +export { + sendMsgToFCL, + ready, + close, + approve, + decline, + redirect, +} from "./send-msg-to-fcl" +export {onMessageFromFCL} from "./on-message-from-fcl" +export {encodeMessageFromSignable} from "@onflow/sdk" +export {CompositeSignature} from "./CompositeSignature" +export {encodeAccountProof} from "./encode-account-proof" +export {injectExtService} from "./inject-ext-service" diff --git a/packages/fcl-core/src/wallet-utils/inject-ext-service.js b/packages/fcl-core/src/wallet-utils/inject-ext-service.js deleted file mode 100644 index b5c607707..000000000 --- a/packages/fcl-core/src/wallet-utils/inject-ext-service.js +++ /dev/null @@ -1,10 +0,0 @@ -export function injectExtService(service) { - if (service.type === "authn" && service.endpoint != null) { - if (!Array.isArray(window.fcl_extensions)) { - window.fcl_extensions = [] - } - window.fcl_extensions.push(service) - } else { - console.warn("Authn service is required") - } -} diff --git a/packages/fcl-core/src/wallet-utils/inject-ext-service.ts b/packages/fcl-core/src/wallet-utils/inject-ext-service.ts new file mode 100644 index 000000000..7380598e4 --- /dev/null +++ b/packages/fcl-core/src/wallet-utils/inject-ext-service.ts @@ -0,0 +1,31 @@ +import type {Service} from "@onflow/typedefs" + +/** + * @description Injects an external authentication service into the global FCL extensions array. + * This function is used by wallet providers to register their authentication services with FCL, + * making them available for user authentication. The service must be of type "authn" and have + * a valid endpoint. + * + * @param service The authentication service to inject. Must have type "authn" and a valid endpoint + * + * @example + * // Register a wallet authentication service + * const walletService = { + * type: "authn", + * endpoint: "https://example-wallet.com/fcl/authn", + * method: "HTTP/POST", + * identity: { address: "0x123..." }, + * provider: { name: "Example Wallet" } + * } + * fcl.WalletUtils.injectExtService(walletService) + */ +export function injectExtService(service: Service): void { + if (service.type === "authn" && service.endpoint != null) { + if (!Array.isArray((window as any).fcl_extensions)) { + ;(window as any).fcl_extensions = [] + } + ;(window as any).fcl_extensions.push(service) + } else { + console.warn("Authn service is required") + } +} diff --git a/packages/fcl-core/src/wallet-utils/on-message-from-fcl.js b/packages/fcl-core/src/wallet-utils/on-message-from-fcl.js deleted file mode 100644 index 585269fe2..000000000 --- a/packages/fcl-core/src/wallet-utils/on-message-from-fcl.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @description - * Listens for messages from FCL - * - * @param {string} messageType - Message type - * @param {Function} cb - Callback function - * @returns {Function} - Function to remove event listener - */ -export const onMessageFromFCL = (messageType, cb = () => {}) => { - const buildData = data => { - if (data.deprecated) - console.warn("DEPRECATION NOTICE", data.deprecated.message) - delete data?.body?.interaction - - return data - } - - const internal = e => { - const {data, origin} = e - if (typeof data !== "object") return - if (typeof data == null) return - if (data.type !== messageType) return - - cb(buildData(data), {origin}) - } - - window.addEventListener("message", internal) - return () => window.removeEventListener("message", internal) -} diff --git a/packages/fcl-core/src/wallet-utils/on-message-from-fcl.ts b/packages/fcl-core/src/wallet-utils/on-message-from-fcl.ts new file mode 100644 index 000000000..e9cc3fb67 --- /dev/null +++ b/packages/fcl-core/src/wallet-utils/on-message-from-fcl.ts @@ -0,0 +1,60 @@ +/** + * @description Sets up a message listener to receive messages from the parent FCL application. This + * function is used by wallet services to listen for specific message types from FCL and respond + * accordingly. It handles message filtering, data sanitization, and provides context about the + * message origin for security purposes. + * + * @param messageType The specific message type to listen for (e.g., "FCL:VIEW:READY:RESPONSE") + * @param cb Callback function executed when a matching message is received + * @param cb.data The message data received from FCL, with deprecated fields removed + * @param cb.context Context object providing security information + * @param cb.context.origin The origin URL of the FCL application sending the message + * + * @returns Function to remove the event listener and stop listening for messages + * + * @example + * // Listen for authentication requests from FCL + * import { onMessageFromFCL } from "@onflow/fcl" + * + * const removeListener = onMessageFromFCL("FCL:VIEW:READY:RESPONSE", (data, context) => { + * console.log("FCL is ready for communication") + * console.log("Message from:", context.origin) + * console.log("Ready data:", data) + * + * // Verify origin for security + * if (context.origin === "https://myapp.com") { + * initializeWalletServices() + * } else { + * console.warn("Unexpected origin:", context.origin) + * } + * }) + * + * // Stop listening when wallet service closes + * window.addEventListener("beforeunload", () => { + * removeListener() + * }) + */ +export const onMessageFromFCL = ( + messageType: string, + cb: (data: any, context: {origin: string}) => void = () => {} +): (() => void) => { + const buildData = (data: any): any => { + if (data.deprecated) + console.warn("DEPRECATION NOTICE", data.deprecated.message) + delete data?.body?.interaction + + return data + } + + const internal = (e: MessageEvent): void => { + const {data, origin} = e + if (typeof data !== "object") return + if (typeof data == null) return + if (data.type !== messageType) return + + cb(buildData(data), {origin}) + } + + window.addEventListener("message", internal) + return () => window.removeEventListener("message", internal) +} diff --git a/packages/fcl-core/src/wallet-utils/send-msg-to-fcl.js b/packages/fcl-core/src/wallet-utils/send-msg-to-fcl.js deleted file mode 100644 index 2362dc177..000000000 --- a/packages/fcl-core/src/wallet-utils/send-msg-to-fcl.js +++ /dev/null @@ -1,115 +0,0 @@ -import { - FCL_REDIRECT_URL_PARAM_NAME, - FCL_RESPONSE_PARAM_NAME, -} from "../utils/constants" -import {onMessageFromFCL} from "./on-message-from-fcl" -import {URL} from "../utils/url" - -/** - * @description - * Sends message to FCL window - * - * @param {string} type - Message type - * @param {object} msg - Message object - * @returns {void} - * - * @example - * sendMsgToFCL("FCL:VIEW:RESPONSE", { - * f_type: "PollingResponse", - * f_vsn: "1.0.0", - * status: "APPROVED", - * reason: null, - * data: data, - * }) - */ -export const sendMsgToFCL = (type, msg = {}) => { - const data = {...msg, type} - - const urlParams = new URLSearchParams(window.location.search) - const redirectUrl = urlParams.get(FCL_REDIRECT_URL_PARAM_NAME) - if (redirectUrl) { - const url = new URL(redirectUrl) - url.searchParams.append(FCL_RESPONSE_PARAM_NAME, JSON.stringify(data)) - window.location.href = url.href - } else if (window.location !== window.parent.location) { - window.parent.postMessage({...msg, type}, "*") - } else if (window.opener) { - window.opener.postMessage({...msg, type}, "*") - } else { - throw new Error("Unable to communicate with parent FCL instance") - } -} - -/** - * @description - * Listens for "FCL:VIEW:READY:RESPONSE" and sends "FCL:VIEW:READY" - * - * @param {Function} cb - Callback function - * @param {object} msg - Message object - * @returns {void} - */ -export const ready = (cb, msg = {}) => { - onMessageFromFCL("FCL:VIEW:READY:RESPONSE", cb) - sendMsgToFCL("FCL:VIEW:READY") -} - -/** - * @description - * Sends "FCL:VIEW:CLOSE" - * - * @returns {void} - */ -export const close = () => { - sendMsgToFCL("FCL:VIEW:CLOSE") -} - -/** - * @description - * Sends "FCL:VIEW:RESPONSE" with status "APPROVED" - * - * @param {object} data - Data object - * @returns {void} - */ -export const approve = data => { - sendMsgToFCL("FCL:VIEW:RESPONSE", { - f_type: "PollingResponse", - f_vsn: "1.0.0", - status: "APPROVED", - reason: null, - data: data, - }) -} - -/** - * @description - * Sends "FCL:VIEW:RESPONSE" with status "DECLINED" - * - * @param {string} reason - Reason for declining - * @returns {void} - */ -export const decline = reason => { - sendMsgToFCL("FCL:VIEW:RESPONSE", { - f_type: "PollingResponse", - f_vsn: "1.0.0", - status: "DECLINED", - reason: reason, - data: null, - }) -} - -/** - * @description - * Sends "FCL:VIEW:RESPONSE" with status "REDIRECT" - * - * @param {object} data - Data object - * @returns {void} - */ -export const redirect = data => { - sendMsgToFCL("FCL:VIEW:RESPONSE", { - f_type: "PollingResponse", - f_vsn: "1.0.0", - status: "REDIRECT", - reason: null, - data: data, - }) -} diff --git a/packages/fcl-core/src/wallet-utils/send-msg-to-fcl.ts b/packages/fcl-core/src/wallet-utils/send-msg-to-fcl.ts new file mode 100644 index 000000000..54bff0dee --- /dev/null +++ b/packages/fcl-core/src/wallet-utils/send-msg-to-fcl.ts @@ -0,0 +1,199 @@ +import { + FCL_REDIRECT_URL_PARAM_NAME, + FCL_RESPONSE_PARAM_NAME, +} from "../utils/constants" +import {onMessageFromFCL} from "./on-message-from-fcl" + +export interface PollingResponse { + f_type: "PollingResponse" + f_vsn: "1.0.0" + status: "APPROVED" | "DECLINED" | "REDIRECT" + reason: string | null + data: any +} + +/** + * @description Sends messages from a wallet or service back to the parent FCL application. This function + * handles communication between wallet UIs (running in iframes, popups, or redirects) and the main FCL + * application. It automatically detects the communication method (redirect, iframe, or popup) and sends + * the message accordingly. + * + * @param type The message type identifier (e.g., "FCL:VIEW:RESPONSE", "FCL:VIEW:READY") + * @param msg Optional message payload containing response data + * @param msg.f_type FCL message format type, should be "PollingResponse" + * @param msg.f_vsn FCL message format version, should be "1.0.0" + * @param msg.status Response status + * @param msg.reason Reason for the response (especially for DECLINED status) + * @param msg.data Actual response data (signatures, account info, etc.) + * + * @throws When unable to communicate with parent FCL instance + * + * @example + * // Send approval response with signature data + * import { sendMsgToFCL } from "@onflow/fcl" + * + * sendMsgToFCL("FCL:VIEW:RESPONSE", { + * f_type: "CompositeSignature", + * f_vsn: "1.0.0", + * addr: "0x1234567890abcdef", + * keyId: 0, + * signature: "abc123..." + * }) + */ +export const sendMsgToFCL = (type: string, msg?: PollingResponse): void => { + const data = {...msg, type} + + const urlParams = new URLSearchParams(window.location.search) + const redirectUrl = urlParams.get(FCL_REDIRECT_URL_PARAM_NAME) + if (redirectUrl) { + const url = new URL(redirectUrl) + url.searchParams.append(FCL_RESPONSE_PARAM_NAME, JSON.stringify(data)) + window.location.href = url.href + } else if (window.location !== window.parent.location) { + window.parent.postMessage({...msg, type}, "*") + } else if (window.opener) { + window.opener.postMessage({...msg, type}, "*") + } else { + throw new Error("Unable to communicate with parent FCL instance") + } +} + +/** + * @description Initiates the communication handshake between a wallet service and FCL. This function + * listens for the "FCL:VIEW:READY:RESPONSE" message from FCL and automatically sends "FCL:VIEW:READY" + * to indicate the wallet service is ready to receive requests. This is typically the first function + * called when a wallet service loads. + * + * @param cb Callback function executed when FCL responds with ready confirmation + * @param cb.data Data received from FCL ready response + * @param cb.context Context object containing origin information + * @param cb.context.origin Origin of the FCL application + * @param msg Optional message payload to include with ready signal + * + * @example + * // Basic wallet service initialization + * import { ready } from "@onflow/fcl" + * + * ready((data, context) => { + * console.log("FCL is ready to communicate") + * console.log("FCL origin:", context.origin) + * console.log("Ready data:", data) + * + * // Wallet service is now ready to handle authentication requests + * initializeWalletUI() + * }) + */ +export const ready = ( + cb: (data: any, context: {origin: string}) => void, + msg: PollingResponse = {} as PollingResponse +): void => { + onMessageFromFCL("FCL:VIEW:READY:RESPONSE", cb) + sendMsgToFCL("FCL:VIEW:READY") +} + +/** + * @description Closes the wallet service window/iframe and notifies FCL that the service is shutting down. + * This should be called when the user cancels an operation or when the wallet service needs to close itself. + * + * Sends "FCL:VIEW:CLOSE". + */ +export const close = (): void => { + sendMsgToFCL("FCL:VIEW:CLOSE") +} + +/** + * @description Sends an approval response to FCL with the provided data. This indicates that the user + * has approved the requested operation (authentication, transaction signing, etc.) and includes the + * resulting data (signatures, account information, etc.). + * + * Sends "FCL:VIEW:RESPONSE". with status "APPROVED". + * + * @param data The approval data to send back to FCL (signatures, account info, etc.) + * + * @example + * // Approve authentication with account data + * import { approve } from "@onflow/fcl" + * + * const accountData = { + * f_type: "AuthnResponse", + * f_vsn: "1.0.0", + * addr: "0x1234567890abcdef", + * services: [ + * { + * f_type: "Service", + * f_vsn: "1.0.0", + * type: "authz", + * method: "HTTP/POST", + * endpoint: "https://wallet.example.com/authz" + * } + * ] + * } + * + * approve(accountData) + */ +export const approve = (data: any): void => { + sendMsgToFCL("FCL:VIEW:RESPONSE", { + f_type: "PollingResponse", + f_vsn: "1.0.0", + status: "APPROVED", + reason: null, + data: data, + }) +} + +/** + * @description Sends a decline response to FCL indicating that the user has rejected or cancelled + * the requested operation. This should be called when the user explicitly cancels an operation + * or when an error prevents the operation from completing. + * + * Sends "FCL:VIEW:RESPONSE". with status "DECLINED". + * + * @param reason Human-readable reason for declining the request + * + * @example + * // Decline when user cancels authentication + * import { decline } from "@onflow/fcl" + * + * document.getElementById('cancel-btn').addEventListener('click', () => { + * decline("User cancelled authentication") + * }) + */ +export const decline = (reason: string): void => { + sendMsgToFCL("FCL:VIEW:RESPONSE", { + f_type: "PollingResponse", + f_vsn: "1.0.0", + status: "DECLINED", + reason: reason, + data: null, + }) +} + +/** + * @description Sends a redirect response to FCL indicating that the operation requires a redirect + * to complete. This is used when the wallet service needs to redirect the user to another URL + * (such as a native app deep link or external service). + * + * Sends "FCL:VIEW:RESPONSE". with status "REDIRECT". + * + * @param data Redirect data containing the target URL and any additional parameters + * + * @example + * // Redirect to native wallet app + * import { redirect } from "@onflow/fcl" + * + * redirect({ + * f_type: "RedirectResponse", + * f_vsn: "1.0.0", + * url: "flow-wallet://sign?transaction=abc123", + * callback: "https://myapp.com/callback" + * }) + */ +export const redirect = (data: any): void => { + sendMsgToFCL("FCL:VIEW:RESPONSE", { + f_type: "PollingResponse", + f_vsn: "1.0.0", + status: "REDIRECT", + reason: null, + data: data, + }) +} diff --git a/packages/fcl-core/src/wallet-utils/wallet-utils.test.js b/packages/fcl-core/src/wallet-utils/wallet-utils.test.ts similarity index 100% rename from packages/fcl-core/src/wallet-utils/wallet-utils.test.js rename to packages/fcl-core/src/wallet-utils/wallet-utils.test.ts diff --git a/packages/fcl-ethereum-provider/src/constants.ts b/packages/fcl-ethereum-provider/src/constants.ts index a0b530b1a..b3f2948be 100644 --- a/packages/fcl-ethereum-provider/src/constants.ts +++ b/packages/fcl-ethereum-provider/src/constants.ts @@ -1,7 +1,9 @@ -export enum FlowNetwork { - MAINNET = "mainnet", - TESTNET = "testnet", -} +export const FlowNetwork = { + MAINNET: "mainnet", + TESTNET: "testnet", +} as const + +export type FlowNetwork = (typeof FlowNetwork)[keyof typeof FlowNetwork] export const FLOW_CHAINS = { [FlowNetwork.MAINNET]: { @@ -14,14 +16,18 @@ export const FLOW_CHAINS = { }, } -export enum ContractType { - EVM = "EVM", -} +export const ContractType = { + EVM: "EVM", +} as const -export enum EventType { - CADENCE_OWNED_ACCOUNT_CREATED = "CADENCE_OWNED_ACCOUNT_CREATED", - TRANSACTION_EXECUTED = "TRANSACTION_EXECUTED", -} +export type ContractType = (typeof ContractType)[keyof typeof ContractType] + +export const EventType = { + CADENCE_OWNED_ACCOUNT_CREATED: "CADENCE_OWNED_ACCOUNT_CREATED", + TRANSACTION_EXECUTED: "TRANSACTION_EXECUTED", +} as const + +export type EventType = (typeof EventType)[keyof typeof EventType] export const EVENT_IDENTIFIERS = { [EventType.TRANSACTION_EXECUTED]: { diff --git a/packages/fcl-ethereum-provider/src/util/errors.ts b/packages/fcl-ethereum-provider/src/util/errors.ts index 42466a55f..97c6dc0bd 100644 --- a/packages/fcl-ethereum-provider/src/util/errors.ts +++ b/packages/fcl-ethereum-provider/src/util/errors.ts @@ -1,29 +1,33 @@ -export enum ProviderErrorCode { +export const ProviderErrorCode = { // EIP-1193 error codes - UserRejectedRequest = 4001, - Unauthorized = 4100, - UnsupportedMethod = 4200, - Disconnected = 4900, + UserRejectedRequest: 4001, + Unauthorized: 4100, + UnsupportedMethod: 4200, + Disconnected: 4900, // EIP-1474 / JSON-RPC error codes - ParseError = -32700, - InvalidRequest = -32600, - MethodNotFound = -32601, - InvalidParams = -32602, - InternalError = -32603, -} + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, +} as const + +export type ProviderErrorCode = + (typeof ProviderErrorCode)[keyof typeof ProviderErrorCode] + export const ProviderErrorMessage: Record = { // EIP-1193 error messages - [4001]: "User rejected request", - [4100]: "Unauthorized", - [4200]: "Unsupported method", - [4900]: "Disconnected", + [ProviderErrorCode.UserRejectedRequest]: "User rejected request", + [ProviderErrorCode.Unauthorized]: "Unauthorized", + [ProviderErrorCode.UnsupportedMethod]: "Unsupported method", + [ProviderErrorCode.Disconnected]: "Disconnected", // EIP-1474 / JSON-RPC error messages - [-32700]: "Parse error", - [-32600]: "Invalid request", - [-32601]: "Method not found", - [-32602]: "Invalid params", - [-32603]: "Internal error", + [ProviderErrorCode.ParseError]: "Parse error", + [ProviderErrorCode.InvalidRequest]: "Invalid request", + [ProviderErrorCode.MethodNotFound]: "Method not found", + [ProviderErrorCode.InvalidParams]: "Invalid params", + [ProviderErrorCode.InternalError]: "Internal error", } export class ProviderError extends Error { diff --git a/packages/fcl-react-native/docs-generator.config.js b/packages/fcl-react-native/docs-generator.config.js new file mode 100644 index 000000000..28116485e --- /dev/null +++ b/packages/fcl-react-native/docs-generator.config.js @@ -0,0 +1,10 @@ +const fs = require("fs") +const path = require("path") + +module.exports = { + customData: { + extra: fs + .readFileSync(path.join(__dirname, "docs", "extra.md"), "utf8") + .trim(), + }, +} diff --git a/packages/fcl-react-native/docs/extra.md b/packages/fcl-react-native/docs/extra.md new file mode 100644 index 000000000..e13c4366c --- /dev/null +++ b/packages/fcl-react-native/docs/extra.md @@ -0,0 +1,178 @@ +## Configuration + +FCL has a mechanism that lets you configure various aspects of FCL. When you move from one instance of the Flow Blockchain to another (Local Emulator to Testnet to Mainnet) the only thing you should need to change for your FCL implementation is your configuration. + +### Setting Configuration Values + +Values only need to be set once. We recommend doing this once and as early in the life cycle as possible. To set a configuration value, the `put` method on the `config` instance needs to be called, the `put` method returns the `config` instance so they can be chained. + +Alternatively, you can set the config by passing a JSON object directly. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl + .config() // returns the config instance + .put('foo', 'bar') // configures "foo" to be "bar" + .put('baz', 'buz'); // configures "baz" to be "buz" + +// OR + +fcl.config({ + foo: 'bar', + baz: 'buz', +}); +``` + +### Getting Configuration Values + +The `config` instance has an **asynchronous** `get` method. You can also pass it a fallback value. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl.config().put('foo', 'bar').put('woot', 5).put('rawr', 7); + +const FALLBACK = 1; + +async function addStuff() { + var woot = await fcl.config().get('woot', FALLBACK); // will be 5 -- set in the config before + var rawr = await fcl.config().get('rawr', FALLBACK); // will be 7 -- set in the config before + var hmmm = await fcl.config().get('hmmm', FALLBACK); // will be 1 -- uses fallback because this isnt in the config + + return woot + rawr + hmmm; +} + +addStuff().then((d) => console.log(d)); // 13 (5 + 7 + 1) +``` + +### Common Configuration Keys + +| Name | Example | Description | +| ------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `accessNode.api` **(required)** | `https://rest-testnet.onflow.org` | API URL for the Flow Blockchain Access Node you want to be communicating with. See all available access node endpoints [here](https://developers.onflow.org/http-api/). | +| `app.detail.title` | `Cryptokitties` | Your applications title, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.icon` | `https://fcl-discovery.onflow.org/images/blocto.png` | Url for your applications icon, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.description` | `Cryptokitties is a blockchain game` | Your applications description, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.url` | `https://cryptokitties.co` | Your applications url, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `challenge.handshake` | **DEPRECATED** | Use `discovery.wallet` instead. | +| `discovery.authn.endpoint` | `https://fcl-discovery.onflow.org/api/testnet/authn` | Endpoint for alternative configurable Wallet Discovery mechanism. | +| `discovery.wallet` **(required)** | `https://fcl-discovery.onflow.org/testnet/authn` | Points FCL at the Wallet or Wallet Discovery mechanism. | +| `discovery.wallet.method` | `IFRAME/RPC`, `POP/RPC`, `TAB/RPC`, `HTTP/POST`, or `EXT/RPC` | Describes which service strategy a wallet should use. | +| `fcl.limit` | `100` | Specifies fallback compute limit if not provided in transaction. Provided as integer. | +| `flow.network` **(recommended)** | `testnet` | Used in conjunction with stored interactions and provides FCLCryptoContract address for `testnet` and `mainnet`. Possible values: `local`, `testnet`, `mainnet`. | +| `walletconnect.projectId` | `YOUR_PROJECT_ID` | Your app's WalletConnect project ID. See [WalletConnect Cloud](https://cloud.walletconnect.com/sign-in) to obtain a project ID for your application. | +| `walletconnect.disableNotifications` | `false` | Optional flag to disable pending WalletConnect request notifications within the application's UI. | + +## Using Contracts in Scripts and Transactions + +### Address Replacement + +Configuration keys that start with `0x` will be replaced in FCL scripts and transactions, this allows you to write your script or transaction Cadence code once and not have to change it when you point your application at a difference instance of the Flow Blockchain. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl.config().put('0xFungibleToken', '0xf233dcee88fe0abe'); + +async function myScript() { + return fcl + .send([ + fcl.script` + import FungibleToken from 0xFungibleToken // will be replaced with 0xf233dcee88fe0abe because of the configuration + + access(all) fun main() { /* Rest of the script goes here */ } + `, + ]) + .then(fcl.decode); +} + +async function myTransaction() { + return fcl + .send([ + fcl.transaction` + import FungibleToken from 0xFungibleToken // will be replaced with 0xf233dcee88fe0abe because of the configuration + + transaction { /* Rest of the transaction goes here */ } + `, + ]) + .then(fcl.decode); +} +``` + +#### Example + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl + .config() + .put('flow.network', 'testnet') + .put('walletconnect.projectId', 'YOUR_PROJECT_ID') + .put('accessNode.api', 'https://rest-testnet.onflow.org') + .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn') + .put('app.detail.title', 'Test Harness') + .put('app.detail.icon', 'https://i.imgur.com/r23Zhvu.png') + .put('app.detail.description', 'A test harness for FCL') + .put('app.detail.url', 'https://myapp.com') + .put('service.OpenID.scopes', 'email email_verified name zoneinfo') + .put('0xFlowToken', '0x7e60df042a9c0868'); +``` + +### Using `flow.json` for Contract Imports + +A simpler and more flexible way to manage contract imports in scripts and transactions is by using the `config.load` method in FCL. This lets you load contract configurations from a `flow.json` file, keeping your import syntax clean and allowing FCL to pick the correct contract addresses based on the network you're using. + +#### 1. Define Your Contracts in `flow.json` + +Here’s an example of a `flow.json` file with aliases for multiple networks: + +```json +{ + "contracts": { + "HelloWorld": { + "source": "./cadence/contracts/HelloWorld.cdc", + "aliases": { + "testnet": "0x1cf0e2f2f715450", + "mainnet": "0xf8d6e0586b0a20c7" + } + } + } +} +``` + +- **`source`**: Points to the contract file in your project. +- **`aliases`**: Maps each network to the correct contract address. + +#### 2. Configure FCL + +Load the `flow.json` file and set up FCL to use it: + +```javascript +import { config } from '@onflow/fcl'; +import flowJSON from '../flow.json'; + +config({ + 'flow.network': 'testnet', // Choose your network, e.g., testnet or mainnet + 'accessNode.api': 'https://rest-testnet.onflow.org', // Access node for the network + 'discovery.wallet': `https://fcl-discovery.onflow.org/testnet/authn`, // Wallet discovery +}).load({ flowJSON }); +``` + +With this setup, FCL will automatically use the correct contract address based on the selected network (e.g., `testnet` or `mainnet`). + +#### 3. Use Contract Names in Scripts and Transactions + +After setting up `flow.json`, you can import contracts by name in your Cadence scripts or transactions: + +```cadence +import "HelloWorld" + +access(all) fun main(): String { + return HelloWorld.sayHello() +} +``` + +FCL replaces `"HelloWorld"` with the correct address from the `flow.json` configuration. + +> **Note**: Don’t store private keys in your `flow.json`. Instead, keep sensitive keys in a separate, `.gitignore`-protected file. \ No newline at end of file diff --git a/packages/fcl-react-native/package.json b/packages/fcl-react-native/package.json index ed6f20f56..f91ca4049 100644 --- a/packages/fcl-react-native/package.json +++ b/packages/fcl-react-native/package.json @@ -1,7 +1,7 @@ { "name": "@onflow/fcl-react-native", "version": "1.12.1", - "description": "Flow Client Library", + "description": "React Native JavaScript/TypeScript library for building mobile applications on the Flow blockchain.", "license": "Apache-2.0", "author": "Flow Foundation", "homepage": "https://flow.com", @@ -43,7 +43,8 @@ "build": "npm run lint && fcl-bundle", "build:types": "tsc", "start": "fcl-bundle --watch", - "lint": "eslint ." + "lint": "eslint .", + "generate-docs": "node ../../docs-generator/generate-docs.js" }, "dependencies": { "@babel/runtime": "^7.25.7", diff --git a/packages/fcl-react-native/src/VERSION.js b/packages/fcl-react-native/src/VERSION.js deleted file mode 100644 index 5883643f2..000000000 --- a/packages/fcl-react-native/src/VERSION.js +++ /dev/null @@ -1 +0,0 @@ -export const VERSION = PACKAGE_CURRENT_VERSION || "TESTVERSION" diff --git a/packages/fcl-react-native/src/VERSION.ts b/packages/fcl-react-native/src/VERSION.ts new file mode 100644 index 000000000..d9ff78b73 --- /dev/null +++ b/packages/fcl-react-native/src/VERSION.ts @@ -0,0 +1,3 @@ +declare const PACKAGE_CURRENT_VERSION: string | undefined + +export const VERSION: string = PACKAGE_CURRENT_VERSION || "TESTVERSION" diff --git a/packages/fcl-react-native/src/fcl-react-native.ts b/packages/fcl-react-native/src/fcl-react-native.ts index bc11379fa..32dfbf5d8 100644 --- a/packages/fcl-react-native/src/fcl-react-native.ts +++ b/packages/fcl-react-native/src/fcl-react-native.ts @@ -112,3 +112,5 @@ export {useServiceDiscovery, ServiceDiscovery} // Subscriptions export {subscribe} from "@onflow/fcl-core" export {subscribeRaw} from "@onflow/fcl-core" + +export * from "@onflow/typedefs" diff --git a/packages/fcl-wc/src/constants.ts b/packages/fcl-wc/src/constants.ts index 0e5d09c47..dc8119510 100644 --- a/packages/fcl-wc/src/constants.ts +++ b/packages/fcl-wc/src/constants.ts @@ -1,14 +1,18 @@ -export enum FLOW_METHODS { - FLOW_AUTHN = "flow_authn", - FLOW_PRE_AUTHZ = "flow_pre_authz", - FLOW_AUTHZ = "flow_authz", - FLOW_USER_SIGN = "flow_user_sign", -} - -export enum REQUEST_TYPES { - SESSION_REQUEST = "session_proposal", - SIGNING_REQUEST = "signing_request", -} +export const FLOW_METHODS = { + FLOW_AUTHN: "flow_authn", + FLOW_PRE_AUTHZ: "flow_pre_authz", + FLOW_AUTHZ: "flow_authz", + FLOW_USER_SIGN: "flow_user_sign", +} as const + +export type FLOW_METHODS = (typeof FLOW_METHODS)[keyof typeof FLOW_METHODS] + +export const REQUEST_TYPES = { + SESSION_REQUEST: "session_proposal", + SIGNING_REQUEST: "signing_request", +} as const + +export type REQUEST_TYPES = (typeof REQUEST_TYPES)[keyof typeof REQUEST_TYPES] export const SERVICE_PLUGIN_NAME = "fcl-plugin-service-walletconnect" export const WC_SERVICE_METHOD = "WC/RPC" diff --git a/packages/fcl/README.md b/packages/fcl/README.md index 11327d6e4..9a584620c 100644 --- a/packages/fcl/README.md +++ b/packages/fcl/README.md @@ -136,10 +136,10 @@ const txId = await fcl.mutate({ ## Typescript Support -FCL JS supports TypeScript. If you need to import specific types, you can do so via the [@onflow/typedefs](../typedefs/README.md) package. +FCL JS comes with TypeScript support. If you need to use specific types, you can import them directly from the @onflow/fcl package. ```typescript -import {CurrentUser} from "@onflow/typedefs" +import {CurrentUser} from "@onflow/fcl" const newUser: CurrentUser = { addr: null, diff --git a/packages/fcl/docs-generator.config.js b/packages/fcl/docs-generator.config.js new file mode 100644 index 000000000..28116485e --- /dev/null +++ b/packages/fcl/docs-generator.config.js @@ -0,0 +1,10 @@ +const fs = require("fs") +const path = require("path") + +module.exports = { + customData: { + extra: fs + .readFileSync(path.join(__dirname, "docs", "extra.md"), "utf8") + .trim(), + }, +} diff --git a/packages/fcl/docs/extra.md b/packages/fcl/docs/extra.md new file mode 100644 index 000000000..e13c4366c --- /dev/null +++ b/packages/fcl/docs/extra.md @@ -0,0 +1,178 @@ +## Configuration + +FCL has a mechanism that lets you configure various aspects of FCL. When you move from one instance of the Flow Blockchain to another (Local Emulator to Testnet to Mainnet) the only thing you should need to change for your FCL implementation is your configuration. + +### Setting Configuration Values + +Values only need to be set once. We recommend doing this once and as early in the life cycle as possible. To set a configuration value, the `put` method on the `config` instance needs to be called, the `put` method returns the `config` instance so they can be chained. + +Alternatively, you can set the config by passing a JSON object directly. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl + .config() // returns the config instance + .put('foo', 'bar') // configures "foo" to be "bar" + .put('baz', 'buz'); // configures "baz" to be "buz" + +// OR + +fcl.config({ + foo: 'bar', + baz: 'buz', +}); +``` + +### Getting Configuration Values + +The `config` instance has an **asynchronous** `get` method. You can also pass it a fallback value. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl.config().put('foo', 'bar').put('woot', 5).put('rawr', 7); + +const FALLBACK = 1; + +async function addStuff() { + var woot = await fcl.config().get('woot', FALLBACK); // will be 5 -- set in the config before + var rawr = await fcl.config().get('rawr', FALLBACK); // will be 7 -- set in the config before + var hmmm = await fcl.config().get('hmmm', FALLBACK); // will be 1 -- uses fallback because this isnt in the config + + return woot + rawr + hmmm; +} + +addStuff().then((d) => console.log(d)); // 13 (5 + 7 + 1) +``` + +### Common Configuration Keys + +| Name | Example | Description | +| ------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `accessNode.api` **(required)** | `https://rest-testnet.onflow.org` | API URL for the Flow Blockchain Access Node you want to be communicating with. See all available access node endpoints [here](https://developers.onflow.org/http-api/). | +| `app.detail.title` | `Cryptokitties` | Your applications title, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.icon` | `https://fcl-discovery.onflow.org/images/blocto.png` | Url for your applications icon, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.description` | `Cryptokitties is a blockchain game` | Your applications description, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `app.detail.url` | `https://cryptokitties.co` | Your applications url, can be requested by wallets and other services. Used by WalletConnect plugin & Wallet Discovery service. | +| `challenge.handshake` | **DEPRECATED** | Use `discovery.wallet` instead. | +| `discovery.authn.endpoint` | `https://fcl-discovery.onflow.org/api/testnet/authn` | Endpoint for alternative configurable Wallet Discovery mechanism. | +| `discovery.wallet` **(required)** | `https://fcl-discovery.onflow.org/testnet/authn` | Points FCL at the Wallet or Wallet Discovery mechanism. | +| `discovery.wallet.method` | `IFRAME/RPC`, `POP/RPC`, `TAB/RPC`, `HTTP/POST`, or `EXT/RPC` | Describes which service strategy a wallet should use. | +| `fcl.limit` | `100` | Specifies fallback compute limit if not provided in transaction. Provided as integer. | +| `flow.network` **(recommended)** | `testnet` | Used in conjunction with stored interactions and provides FCLCryptoContract address for `testnet` and `mainnet`. Possible values: `local`, `testnet`, `mainnet`. | +| `walletconnect.projectId` | `YOUR_PROJECT_ID` | Your app's WalletConnect project ID. See [WalletConnect Cloud](https://cloud.walletconnect.com/sign-in) to obtain a project ID for your application. | +| `walletconnect.disableNotifications` | `false` | Optional flag to disable pending WalletConnect request notifications within the application's UI. | + +## Using Contracts in Scripts and Transactions + +### Address Replacement + +Configuration keys that start with `0x` will be replaced in FCL scripts and transactions, this allows you to write your script or transaction Cadence code once and not have to change it when you point your application at a difference instance of the Flow Blockchain. + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl.config().put('0xFungibleToken', '0xf233dcee88fe0abe'); + +async function myScript() { + return fcl + .send([ + fcl.script` + import FungibleToken from 0xFungibleToken // will be replaced with 0xf233dcee88fe0abe because of the configuration + + access(all) fun main() { /* Rest of the script goes here */ } + `, + ]) + .then(fcl.decode); +} + +async function myTransaction() { + return fcl + .send([ + fcl.transaction` + import FungibleToken from 0xFungibleToken // will be replaced with 0xf233dcee88fe0abe because of the configuration + + transaction { /* Rest of the transaction goes here */ } + `, + ]) + .then(fcl.decode); +} +``` + +#### Example + +```javascript +import * as fcl from '@onflow/fcl'; + +fcl + .config() + .put('flow.network', 'testnet') + .put('walletconnect.projectId', 'YOUR_PROJECT_ID') + .put('accessNode.api', 'https://rest-testnet.onflow.org') + .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn') + .put('app.detail.title', 'Test Harness') + .put('app.detail.icon', 'https://i.imgur.com/r23Zhvu.png') + .put('app.detail.description', 'A test harness for FCL') + .put('app.detail.url', 'https://myapp.com') + .put('service.OpenID.scopes', 'email email_verified name zoneinfo') + .put('0xFlowToken', '0x7e60df042a9c0868'); +``` + +### Using `flow.json` for Contract Imports + +A simpler and more flexible way to manage contract imports in scripts and transactions is by using the `config.load` method in FCL. This lets you load contract configurations from a `flow.json` file, keeping your import syntax clean and allowing FCL to pick the correct contract addresses based on the network you're using. + +#### 1. Define Your Contracts in `flow.json` + +Here’s an example of a `flow.json` file with aliases for multiple networks: + +```json +{ + "contracts": { + "HelloWorld": { + "source": "./cadence/contracts/HelloWorld.cdc", + "aliases": { + "testnet": "0x1cf0e2f2f715450", + "mainnet": "0xf8d6e0586b0a20c7" + } + } + } +} +``` + +- **`source`**: Points to the contract file in your project. +- **`aliases`**: Maps each network to the correct contract address. + +#### 2. Configure FCL + +Load the `flow.json` file and set up FCL to use it: + +```javascript +import { config } from '@onflow/fcl'; +import flowJSON from '../flow.json'; + +config({ + 'flow.network': 'testnet', // Choose your network, e.g., testnet or mainnet + 'accessNode.api': 'https://rest-testnet.onflow.org', // Access node for the network + 'discovery.wallet': `https://fcl-discovery.onflow.org/testnet/authn`, // Wallet discovery +}).load({ flowJSON }); +``` + +With this setup, FCL will automatically use the correct contract address based on the selected network (e.g., `testnet` or `mainnet`). + +#### 3. Use Contract Names in Scripts and Transactions + +After setting up `flow.json`, you can import contracts by name in your Cadence scripts or transactions: + +```cadence +import "HelloWorld" + +access(all) fun main(): String { + return HelloWorld.sayHello() +} +``` + +FCL replaces `"HelloWorld"` with the correct address from the `flow.json` configuration. + +> **Note**: Don’t store private keys in your `flow.json`. Instead, keep sensitive keys in a separate, `.gitignore`-protected file. \ No newline at end of file diff --git a/packages/fcl/package.json b/packages/fcl/package.json index c9c7bfe6b..812ee51f8 100644 --- a/packages/fcl/package.json +++ b/packages/fcl/package.json @@ -1,7 +1,7 @@ { "name": "@onflow/fcl", "version": "1.19.0", - "description": "Flow Client Library", + "description": "High-level JavaScript/TypeScript library for building web applications on the Flow blockchain.", "license": "Apache-2.0", "author": "Flow Foundation", "homepage": "https://flow.com", @@ -44,7 +44,8 @@ "build": "npm run lint && fcl-bundle", "build:types": "tsc", "start": "fcl-bundle --watch", - "lint": "eslint ." + "lint": "eslint .", + "generate-docs": "node ../../docs-generator/generate-docs.js" }, "dependencies": { "@babel/runtime": "^7.25.7", diff --git a/packages/fcl/src/VERSION.js b/packages/fcl/src/VERSION.js deleted file mode 100644 index 5883643f2..000000000 --- a/packages/fcl/src/VERSION.js +++ /dev/null @@ -1 +0,0 @@ -export const VERSION = PACKAGE_CURRENT_VERSION || "TESTVERSION" diff --git a/packages/fcl/src/VERSION.ts b/packages/fcl/src/VERSION.ts new file mode 100644 index 000000000..d9ff78b73 --- /dev/null +++ b/packages/fcl/src/VERSION.ts @@ -0,0 +1,3 @@ +declare const PACKAGE_CURRENT_VERSION: string | undefined + +export const VERSION: string = PACKAGE_CURRENT_VERSION || "TESTVERSION" diff --git a/packages/fcl/src/discovery/rpc/requests.ts b/packages/fcl/src/discovery/rpc/requests.ts index b8e20e708..43476a7cc 100644 --- a/packages/fcl/src/discovery/rpc/requests.ts +++ b/packages/fcl/src/discovery/rpc/requests.ts @@ -2,16 +2,21 @@ import {RpcClient, RpcNotification} from "@onflow/util-rpc" export type DiscoveryRpc = RpcClient<{}, DiscoveryNotifications> -export enum DiscoveryNotification { - NOTIFY_QRCODE_CONNECTING = "notifyQrCodeConnecting", - NOTIFY_QRCODE_CONNECTED = "notifyQrCodeConnected", - NOTIFY_QRCODE_ERROR = "notifyQrCodeError", -} +export const DiscoveryNotification = { + NOTIFY_QRCODE_CONNECTING: "notifyQrCodeConnecting", + NOTIFY_QRCODE_CONNECTED: "notifyQrCodeConnected", + NOTIFY_QRCODE_ERROR: "notifyQrCodeError", +} as const -export enum FclRequest { - REQUEST_WALLETCONNECT_QRCODE = "requestWalletConnectQrCode", - EXEC_SERVICE = "execService", -} +export type DiscoveryNotification = + (typeof DiscoveryNotification)[keyof typeof DiscoveryNotification] + +export const FclRequest = { + REQUEST_WALLETCONNECT_QRCODE: "requestWalletConnectQrCode", + EXEC_SERVICE: "execService", +} as const + +export type FclRequest = (typeof FclRequest)[keyof typeof FclRequest] export type DiscoveryNotifications = { [DiscoveryNotification.NOTIFY_QRCODE_CONNECTING]: RpcNotification<{ diff --git a/packages/fcl/src/fcl.ts b/packages/fcl/src/fcl.ts index fdefe588a..6203df2e4 100644 --- a/packages/fcl/src/fcl.ts +++ b/packages/fcl/src/fcl.ts @@ -80,6 +80,71 @@ const discoveryOpts = { execStrategy: execStrategyHook, } +/** + * @description The main current user service for managing user authentication and authorization in Flow applications. + * This service provides a complete interface for wallet connections, user sessions, transaction signing, and user data management. + * It handles the complexity of connecting to various FCL-compatible wallets, managing authentication state, and providing + * authorization functions for transaction signing. + * + * The currentUser service is configured for web platforms and uses the browser's localStorage by default for session persistence. + * It integrates with Flow's discovery service to enable wallet selection and supports both authentication and re-authentication flows. + * + * This service is reactive and provides subscription capabilities to monitor authentication state changes in real-time. + * All wallet interactions are handled through FCL's standardized protocols, ensuring compatibility with the Flow ecosystem. + * + * Returns an object with the following methods: + * ```typescript + * { + * authenticate, // Authenticates the user via FCL-compatible wallets + * unauthenticate, // Logs out the current user and clears session data + * authorization, // Produces authorization details for transaction signing + * signUserMessage, // Signs arbitrary messages with the user's wallet + * subscribe, // Subscribes to authentication state changes + * snapshot, // Returns the current user object snapshot + * resolveArgument // Resolves the current user as a transaction argument + * } + * ``` + * + * @returns A CurrentUserService object + * + * @example + * // Basic authentication flow + * import * as fcl from "@onflow/fcl" + * + * // Configure FCL + * fcl.config({ + * "accessNode.api": "https://rest-testnet.onflow.org", + * "discovery.wallet": "https://fcl-discovery.onflow.org/testnet/authn", + * "flow.network": "testnet" + * }) + * + * // Authenticate user + * const user = await fcl.currentUser.authenticate() + * console.log("User authenticated:", user.addr) + * + * // Check authentication status + * const currentUser = await fcl.currentUser.snapshot() + * if (currentUser.loggedIn) { + * console.log("User is logged in:", currentUser.addr) + * } + * + * // Subscribe to authentication state changes + * import * as fcl from "@onflow/fcl" + * + * const unsubscribe = fcl.currentUser.subscribe((user) => { + * if (user.loggedIn) { + * console.log("User logged in:", user.addr) + * document.getElementById("login-btn").style.display = "none" + * document.getElementById("logout-btn").style.display = "block" + * } else { + * console.log("User logged out") + * document.getElementById("login-btn").style.display = "block" + * document.getElementById("logout-btn").style.display = "none" + * } + * }) + * // Clean up subscription when component unmounts + * window.addEventListener("beforeunload", () => unsubscribe()) + */ export const currentUser = getCurrentUser({ platform: "web", discovery: discoveryOpts, @@ -89,17 +154,225 @@ export const currentUser = getCurrentUser({ ) }, }) + +/** + * @description A transaction execution function that allows you to submit Cadence transactions to the Flow blockchain + * to mutate on-chain state. This function handles the complete transaction lifecycle including building, signing, and + * sending transactions to Flow. It provides a high-level interface that abstracts the complexity of transaction + * construction while offering flexibility for advanced use cases. + * + * The mutate function automatically handles authorization using the current authenticated user by default, but allows + * for custom authorization functions to be specified for different transaction roles (proposer, payer, authorizer). + * It supports both simple single-party transactions and complex multi-party transactions with different signatories. + * + * This function integrates with FCL's address replacement system, allowing you to use placeholder addresses in your + * Cadence code that are replaced with actual addresses at execution time. It also supports Interaction Templates + * for standardized transaction execution patterns. + * + * The mutate function accepts a configuration object with the following structure: + * ```typescript + * { + * cadence?: string, // The Cadence transaction code to execute (required if template not provided) + * args?: Function, // Function that returns an array of arguments for the transaction + * template?: any, // Interaction Template object or URL for standardized transactions + * limit?: number, // Compute (gas) limit for the transaction execution + * authz?: AccountAuthorization, // Authorization function for all signatory roles (proposer, payer, authorizer) + * proposer?: AccountAuthorization, // Specific authorization function for the proposer role + * payer?: AccountAuthorization, // Specific authorization function for the payer role + * authorizations?: AccountAuthorization[] // Array of authorization functions for authorizer roles + * } + * ``` + * + * @param opts Transaction configuration options + * + * @returns Promise that resolves to the transaction ID (txId) when the transaction is submitted + * + * @throws Throws an error if transaction validation fails, required configuration is missing, + * or transaction execution encounters an error + * + * @example + * // Basic transaction submission + * import * as fcl from "@onflow/fcl" + * + * // Configure FCL first + * fcl.config({ + * "accessNode.api": "https://rest-testnet.onflow.org", + * "discovery.wallet": "https://fcl-discovery.onflow.org/testnet/authn", + * "flow.network": "testnet" + * }) + * + * // Authenticate user + * await fcl.authenticate() + * + * // Submit a basic transaction + * const txId = await fcl.mutate({ + * cadence: ` + * transaction(message: String) { + * prepare(account: AuthAccount) { + * log("Transaction executed by: ".concat(account.address.toString())) + * log("Message: ".concat(message)) + * } + * } + * `, + * args: (arg, t) => [ + * arg("Hello Flow!", t.String) + * ], + * limit: 50 + * }) + * + * console.log("Transaction submitted:", txId) + */ export const mutate = getMutate(currentUser) +/** + * @description Calling this method will authenticate the current user via any wallet that supports FCL. Once called, FCL will initiate communication with the configured `discovery.wallet` endpoint which lets the user select a wallet to authenticate with. Once the wallet provider has authenticated the user, FCL will set the values on the current user object for future use and authorization. + * + * This method can only be used in web browsers. + * + * `discovery.wallet` value must be set in the configuration before calling this method. See FCL Configuration. + * + * The default discovery endpoint will open an iframe overlay to let the user choose a supported wallet. + * + * `authenticate` can also take a service returned from discovery with `fcl.authenticate({ service })`. + * + * @param opts Authentication options + * @param opts.service Optional service to use for authentication. A service returned from discovery can be passed here. + * @param opts.redir Optional redirect flag. Defaults to false. + * @param opts.forceReauth Optional force re-authentication flag. Defaults to false. + * @returns Promise that resolves to the authenticated CurrentUser object or undefined + * + * @example + * import * as fcl from '@onflow/fcl'; + * fcl + * .config() + * .put('accessNode.api', 'https://rest-testnet.onflow.org') + * .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn'); + * // anywhere on the page + * fcl.authenticate(); + */ export const authenticate = (opts = {}) => currentUser().authenticate(opts) + +/** + * @description Logs out the current user and sets the values on the current user object to null. + * + * This method can only be used in web browsers. + * + * The current user must be authenticated first. + * + * @example + * import * as fcl from '@onflow/fcl'; + * fcl.config().put('accessNode.api', 'https://rest-testnet.onflow.org'); + * // first authenticate to set current user + * fcl.authenticate(); + * // ... somewhere else & sometime later + * fcl.unauthenticate(); + * // fcl.currentUser.loggedIn === null + */ export const unauthenticate = () => currentUser().unauthenticate() + +/** + * @description A convenience method that calls `fcl.unauthenticate()` and then `fcl.authenticate()` for the current user. + * + * This method can only be used in web browsers. + * + * The current user must be authenticated first. + * + * @param opts Authentication options passed to authenticate method + * @param opts.service Optional service to use for authentication + * @param opts.redir Optional redirect flag. Defaults to false. + * @param opts.forceReauth Optional force re-authentication flag. Defaults to false. + * @returns Promise that resolves to the authenticated CurrentUser object or undefined + * + * @example + * import * as fcl from '@onflow/fcl'; + * // first authenticate to set current user + * fcl.authenticate(); + * // ... somewhere else & sometime later + * fcl.reauthenticate(); + * // logs out user and opens up login/sign-up flow + */ export const reauthenticate = (opts = {}) => { currentUser().unauthenticate() return currentUser().authenticate(opts) } + +/** + * @description A convenience method that calls and is equivalent to `fcl.authenticate()`. + * + * This method can only be used in web browsers. + * + * @param opts Authentication options passed to authenticate method + * @param opts.service Optional service to use for authentication + * @param opts.redir Optional redirect flag. Defaults to false. + * @param opts.forceReauth Optional force re-authentication flag. Defaults to false. + * @returns Promise that resolves to the authenticated CurrentUser object or undefined + * + * @example + * import * as fcl from '@onflow/fcl'; + * fcl.config() + * .put('accessNode.api', 'https://rest-testnet.onflow.org') + * .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn'); + * + * // User clicks sign up button + * fcl.signUp(); + */ export const signUp = (opts = {}) => currentUser().authenticate(opts) + +/** + * @description A convenience method that calls and is equivalent to `fcl.authenticate()`. + * + * This method can only be used in web browsers. + * + * @param opts Authentication options passed to authenticate method + * @param opts.service Optional service to use for authentication + * @param opts.redir Optional redirect flag. Defaults to false. + * @param opts.forceReauth Optional force re-authentication flag. Defaults to false. + * @returns Promise that resolves to the authenticated CurrentUser object or undefined + * + * @example + * import * as fcl from '@onflow/fcl'; + * fcl.config() + * .put('accessNode.api', 'https://rest-testnet.onflow.org') + * .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn'); + * + * // User clicks log in button + * fcl.logIn(); + */ export const logIn = (opts = {}) => currentUser().authenticate(opts) +/** + * @description A convenience method that produces the needed authorization details for the current user to submit transactions to Flow. It defines a signing function that connects to a user's wallet provider to produce signatures to submit transactions. + * + * You can replace this function with your own authorization function if needed. + * + * @returns An object containing the necessary details from the current user to authorize a transaction in any role. + * + * @example + * import * as fcl from '@onflow/fcl'; + * // login somewhere before + * fcl.authenticate(); + * // once logged in authz will produce values + * console.log(fcl.authz); + * // prints {addr, signingFunction, keyId, sequenceNum} from the current authenticated user. + * + * const txId = await fcl.mutate({ + * cadence: ` + * import Profile from 0xba1132bc08f82fe2 + * + * transaction(name: String) { + * prepare(account: auth(BorrowValue) &Account) { + * account.storage.borrow<&{Profile.Owner}>(from: Profile.privatePath)!.setName(name) + * } + * } + * `, + * args: (arg, t) => [arg('myName', t.String)], + * proposer: fcl.authz, // optional - default is fcl.authz + * payer: fcl.authz, // optional - default is fcl.authz + * authorizations: [fcl.authz], // optional - default is [fcl.authz] + * }); + * + * @note The default values for `proposer`, `payer`, and `authorizations` are already `fcl.authz` so there is no need to include these parameters, it is shown only for example purposes. + */ export const authz = currentUser().authorization import {config} from "@onflow/config" @@ -118,3 +391,5 @@ export {LOCAL_STORAGE, SESSION_STORAGE} from "./utils/web" // Subscriptions export {subscribe, subscribeRaw} from "@onflow/fcl-core" + +export * from "@onflow/typedefs" diff --git a/packages/sdk/docs-generator.config.js b/packages/sdk/docs-generator.config.js new file mode 100644 index 000000000..28116485e --- /dev/null +++ b/packages/sdk/docs-generator.config.js @@ -0,0 +1,10 @@ +const fs = require("fs") +const path = require("path") + +module.exports = { + customData: { + extra: fs + .readFileSync(path.join(__dirname, "docs", "extra.md"), "utf8") + .trim(), + }, +} diff --git a/packages/sdk/docs/extra.md b/packages/sdk/docs/extra.md new file mode 100644 index 000000000..74fb4a6b1 --- /dev/null +++ b/packages/sdk/docs/extra.md @@ -0,0 +1,74 @@ +## Connect + +By default, the library uses HTTP to communicate with the access nodes and it must be configured with the correct access node API URL. An error will be returned if the host is unreachable. + +Example: + +```typescript +import { config } from "@onflow/fcl" + +config({ + "accessNode.api": "https://rest-testnet.onflow.org" +}) +``` + +## Querying the Flow Network + +After you have established a connection with an access node, you can query the Flow network to retrieve data about blocks, accounts, events and transactions. We will explore how to retrieve each of these entities in the sections below. + +## Mutate Flow Network + +Flow, like most blockchains, allows anybody to submit a transaction that mutates the shared global chain state. A transaction is an object that holds a payload, which describes the state mutation, and one or more authorizations that permit the transaction to mutate the state owned by specific accounts. + +Transaction data is composed and signed with help of the SDK. The signed payload of transaction then gets submitted to the access node API. If a transaction is invalid or the correct number of authorizing signatures are not provided, it gets rejected. + +## Transactions + +A transaction is nothing more than a signed set of data that includes script code which are instructions on how to mutate the network state and properties that define and limit it's execution. All these properties are explained below. + +**Script** field is the portion of the transaction that describes the state mutation logic. On Flow, transaction logic is written in [Cadence](https://cadence-lang.org/docs). Here is an example transaction script: + +```typescript +transaction(greeting: string) { + execute { + log(greeting.concat(", World!")) + } +} +``` + +**Arguments**. A transaction can accept zero or more arguments that are passed into the Cadence script. The arguments on the transaction must match the number and order declared in the Cadence script. Sample script from above accepts a single `String` argument. + +**Proposal key** must be provided to act as a sequence number and prevent replay and other potential attacks. + +Each account key maintains a separate transaction sequence counter; the key that lends its sequence number to a transaction is called the proposal key. + +A proposal key contains three fields: + +- Account address +- Key index +- Sequence number + +A transaction is only valid if its declared sequence number matches the current on-chain sequence number for that key. The sequence number increments by one after the transaction is executed. + +**Payer** is the account that pays the fees for the transaction. A transaction must specify exactly one payer. The payer is only responsible for paying the network and gas fees; the transaction is not authorized to access resources or code stored in the payer account. + +**Authorizers** are accounts that authorize a transaction to read and mutate their resources. A transaction can specify zero or more authorizers, depending on how many accounts the transaction needs to access. + +The number of authorizers on the transaction must match the number of `&Account` parameters declared in the prepare statement of the Cadence script. + +Example transaction with multiple authorizers: + +```typescript +transaction { + prepare(authorizer1: &Account, authorizer2: &Account) { } +} +``` + +**Gas limit** is the limit on the amount of computation a transaction requires, and it will abort if it exceeds its gas limit. +Cadence uses metering to measure the number of operations per transaction. You can read more about it in the [Cadence documentation](https://cadence-lang.org/docs). + +The gas limit depends on the complexity of the transaction script. Until dedicated gas estimation tooling exists, it's best to use the emulator to test complex transactions and determine a safe limit. + +**Reference block** specifies an expiration window (measured in blocks) during which a transaction is considered valid by the network. +A transaction will be rejected if it is submitted past its expiry block. Flow calculates transaction expiry using the _reference block_ field on a transaction. +A transaction expires after `600` blocks are committed on top of the reference block, which takes about 10 minutes at average Mainnet block rates. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index af5bb5af6..36f567a5d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@onflow/sdk", "version": "1.9.0", - "description": "Flow SDK", + "description": "Low-level JavaScript/TypeScript SDK for interacting with the Flow blockchain.", "license": "Apache-2.0", "author": "Flow Foundation", "homepage": "https://flow.com", @@ -37,7 +37,8 @@ "build": "npm run lint && fcl-bundle", "test:watch": "jest --watch", "start": "fcl-bundle --watch", - "lint": "eslint ." + "lint": "eslint .", + "generate-docs": "node ../../docs-generator/generate-docs.js" }, "dependencies": { "@babel/runtime": "^7.25.7", diff --git a/packages/sdk/src/account/account.ts b/packages/sdk/src/account/account.ts index c617c193a..76d8ff64f 100644 --- a/packages/sdk/src/account/account.ts +++ b/packages/sdk/src/account/account.ts @@ -14,14 +14,54 @@ interface AccountQueryOptions { } /** - * @description Returns the details of an account from their public address + * Retrieve any account from Flow network's latest block or from a specified block height. + * + * Account address is a unique account identifier. Be mindful about the '0x' prefix, you should use the prefix as a default representation but be careful and safely handle user inputs without the prefix. + * + * An account includes the following data: + * - Address: the account address. + * - Balance: balance of the account. + * - Contracts: list of contracts deployed to the account. + * - Keys: list of keys associated with the account. + * * @param address Address of the account * @param queryOptions Query parameters * @param queryOptions.height Block height to query * @param queryOptions.id Block ID to query * @param queryOptions.isSealed Block finality * @param opts Optional parameters - * @returns A promise that resolves to an account response + * @returns A promise that resolves to an Account object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get account from latest block height + * const account = await fcl.account("0x1d007d755706c469"); + * console.log("Address:", account.address); + * console.log("Balance:", account.balance); + * console.log("Keys:", account.keys); + * console.log("Contracts:", Object.keys(account.contracts)); + * + * // Get account at a specific block height + * const historicalAccount = await fcl.account("0x1d007d755706c469", { + * height: 12345 + * }); + * + * // Get account at a specific block ID + * const accountAtBlock = await fcl.account("0x1d007d755706c469", { + * id: "9dda5f281897389b99f103a1c6b180eec9dac870de846449a302103ce38453f3" + * }); + * + * // Get account from sealed block + * const sealedAccount = await fcl.account("0x1d007d755706c469", { + * isSealed: true + * }); + * + * // Alternative using builder pattern + * fcl.send([ + * fcl.getAccount("0x1d007d755706c469"), + * fcl.atBlockHeight(123) + * ]).then(fcl.decode); */ export async function account( address: string, diff --git a/packages/sdk/src/block/block.ts b/packages/sdk/src/block/block.ts index 30ba2d248..4c6d92de7 100644 --- a/packages/sdk/src/block/block.ts +++ b/packages/sdk/src/block/block.ts @@ -13,13 +13,31 @@ interface BlockQueryOptions { } /** - * @description Returns the latest block (optionally sealed or not), by id, or by height + * Query the network for block by id, height or get the latest block. + * + * Block ID is SHA3-256 hash of the entire block payload. This hash is stored as an ID field on any block response object (ie. response from `GetLatestBlock`). + * + * Block height expresses the height of the block on the chain. The latest block height increases by one for every valid block produced. + * * @param queryOptions Query parameters * @param queryOptions.sealed Whether to query for a sealed block * @param queryOptions.height Block height to query * @param queryOptions.id Block ID to query * @param opts Optional parameters - * @returns A promise that resolves to a block response + * @returns A promise that resolves to a Block object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get latest block + * const latestBlock = await fcl.block(); // Get the latest finalized block + * const latestSealedBlock = await fcl.block({sealed: true}); // Get the latest sealed block + * + * // Get block by ID (uses builder function) + * await fcl.send([fcl.getBlock(), fcl.atBlockId("23232323232")]).then(fcl.decode); + * + * // Get block at height (uses builder function) + * await fcl.send([fcl.getBlock(), fcl.atBlockHeight(123)]).then(fcl.decode) */ export async function block( {sealed = false, id, height}: BlockQueryOptions = {}, diff --git a/packages/sdk/src/build/build-arguments.ts b/packages/sdk/src/build/build-arguments.ts index 8c12abd5e..96fdc7f18 100644 --- a/packages/sdk/src/build/build-arguments.ts +++ b/packages/sdk/src/build/build-arguments.ts @@ -2,19 +2,61 @@ import {pipe, makeArgument, CadenceArgument} from "../interaction/interaction" import {TypeDescriptorInput, TypeDescriptor} from "@onflow/types" /** - * @description A utility builder to be used with other builders to pass in arguments with a value and supported type - * @param ax An array of arguments - * @returns An interaction object + * A utility builder to be used with other builders to pass in arguments with a value and supported type. + * + * A transaction can accept zero or more arguments that are passed into the Cadence script. The arguments on the transaction must match the number and order declared in the Cadence script. + * This function returns a Partial Interaction that contains the arguments and types passed in. This alone is a partial and incomplete interaction. + * + * @param ax An array of argument objects created with fcl.arg() + * @returns A Partial Interaction object containing the arguments and types passed in + * + * @example + * import * as fcl from "@onflow/fcl" + * + * await fcl.mutate({ + * cadence: ` + * transaction(amount: UFix64, to: Address) { + * prepare(signer: AuthAccount) { + * // transaction logic + * } + * } + * `, + * args: (arg, t) => [ + * arg("10.0", t.UFix64), // Will be the first argument `amount: UFix64` + * arg("0xba1132bc08f82fe2", t.Address), // Will be the second argument `to: Address` + * ], + * }) */ export function args(ax: CadenceArgument[]) { return pipe(ax.map(makeArgument)) } /** - * @description A utility builder to be used with fcl.args[...] to create FCL supported arguments for interactions - * @param value The value of the argument - * @param xform A function to transform the value - * @returns An argument object + * A utility builder to be used with fcl.args[...] to create FCL supported arguments for interactions. + * + * Arguments are used to pass data to Cadence scripts and transactions. The arguments must match the number and order declared in the Cadence script. + * This function creates an ArgumentObject that holds the value and type passed in. + * + * @param value Any value that you are looking to pass to other builders + * @param xform A type supported by Flow (FType descriptor) + * @returns An ArgumentObject that holds the value and type passed in + * + * @example + * import * as fcl from "@onflow/fcl" + * + * const result = await fcl.query({ + * cadence: ` + * access(all) fun main(a: Int, b: Int, addr: Address): Int { + * log(addr) + * return a + b + * } + * `, + * args: (arg, t) => [ + * arg(7, t.Int), // a: Int + * arg(6, t.Int), // b: Int + * arg("0xba1132bc08f82fe2", t.Address), // addr: Address + * ], + * }); */ export function arg>( value: TypeDescriptorInput, diff --git a/packages/sdk/src/build/build-at-block-height.ts b/packages/sdk/src/build/build-at-block-height.ts index d0485668c..1d480c2d6 100644 --- a/packages/sdk/src/build/build-at-block-height.ts +++ b/packages/sdk/src/build/build-at-block-height.ts @@ -3,9 +3,36 @@ import {Interaction} from "@onflow/typedefs" import {validator} from "./build-validator" /** - * @description A builder function that returns a partial interaction to a block at a specific height - * @param height The height of the block to get - * @returns A function that processes a partial interaction object + * A builder function that returns a partial interaction to a block at a specific height. + * + * Use with other interactions like 'fcl.getBlock()' to get a full interaction at the specified block height. + * + * Block height expresses the height of the block on the chain. The latest block height increases by one for every valid block produced. + * + * @param height The height of the block to execute the interaction at + * @returns A partial interaction to be paired with another interaction such as 'fcl.getBlock()' or 'fcl.getAccount()' + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get block at specific height + * await fcl.send([fcl.getBlock(), fcl.atBlockHeight(123)]).then(fcl.decode); + * + * // Get account at specific block height + * await fcl.send([ + * fcl.getAccount("0x1d007d755706c469"), + * fcl.atBlockHeight(12345) + * ]).then(fcl.decode); + * + * // Execute script at specific block height + * await fcl.send([ + * fcl.script` + * access(all) fun main(): UFix64 { + * return getCurrentBlock().height + * } + * `, + * fcl.atBlockHeight(100) + * ]).then(fcl.decode); */ export function atBlockHeight(height: number): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-at-block-id.ts b/packages/sdk/src/build/build-at-block-id.ts index 7d7ea30fc..66241bd52 100644 --- a/packages/sdk/src/build/build-at-block-id.ts +++ b/packages/sdk/src/build/build-at-block-id.ts @@ -8,6 +8,38 @@ import { import {validator} from "./build-validator" import {Interaction} from "@onflow/typedefs" +/** + * A builder function that returns a partial interaction to a block at a specific block ID. + * + * Use with other interactions like 'fcl.getBlock()' to get a full interaction at the specified block ID. + * + * Block ID is SHA3-256 hash of the entire block payload. This hash is stored as an ID field on any block response object (ie. response from 'GetLatestBlock'). + * + * @param id The ID of the block to execute the interaction at + * @returns A partial interaction to be paired with another interaction such as 'fcl.getBlock()' or 'fcl.getAccount()' + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get block by ID + * await fcl.send([fcl.getBlock(), fcl.atBlockId("23232323232")]).then(fcl.decode); + * + * // Get account at specific block ID + * await fcl.send([ + * fcl.getAccount("0x1d007d755706c469"), + * fcl.atBlockId("9dda5f281897389b99f103a1c6b180eec9dac870de846449a302103ce38453f3") + * ]).then(fcl.decode); + * + * // Execute script at specific block + * await fcl.send([ + * fcl.script` + * access(all) fun main(): UFix64 { + * return getCurrentBlock().timestamp + * } + * `, + * fcl.atBlockId("a1b2c3d4e5f6") + * ]).then(fcl.decode); + */ export function atBlockId(id: string): InteractionBuilderFn { return pipe([ (ix: Interaction) => { diff --git a/packages/sdk/src/build/build-at-latest-block.ts b/packages/sdk/src/build/build-at-latest-block.ts index 115e5fe18..dc6bdefe3 100644 --- a/packages/sdk/src/build/build-at-latest-block.ts +++ b/packages/sdk/src/build/build-at-latest-block.ts @@ -3,9 +3,41 @@ import {Interaction} from "@onflow/typedefs" import {validator} from "./build-validator" /** - * @description A builder function that returns a partial interaction to query the latest block with the given finality state + * A builder function that returns a partial interaction to query the latest block with the given finality state. + * + * Use with other interactions like 'fcl.getBlock()' to get the latest block information. + * Block finality determines whether you get the latest executed block or the latest sealed block. + * + * - Executed blocks (soft-finality): Latest block that has been executed but may not be final + * - Sealed blocks (hard-finality): Latest block that has been sealed and is considered final + * * @param isSealed Block finality state, defaults to latest executed block ("soft-finality"), set to true for sealed blocks ("hard-finality") * @returns A function that processes a partial interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get latest executed block (soft finality) + * await fcl.send([fcl.getBlock(), fcl.atLatestBlock()]).then(fcl.decode); + * + * // Get latest sealed block (hard finality) + * await fcl.send([fcl.getBlock(), fcl.atLatestBlock(true)]).then(fcl.decode); + * + * // Get account from latest sealed block + * await fcl.send([ + * fcl.getAccount("0x1d007d755706c469"), + * fcl.atLatestBlock(true) + * ]).then(fcl.decode); + * + * // Execute script against latest executed block + * await fcl.send([ + * fcl.script` + * access(all) fun main(): UFix64 { + * return getCurrentBlock().height + * } + * `, + * fcl.atLatestBlock() + * ]).then(fcl.decode); */ export function atLatestBlock(isSealed = false): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-authorizations.ts b/packages/sdk/src/build/build-authorizations.ts index 28c249e8a..c609821fc 100644 --- a/packages/sdk/src/build/build-authorizations.ts +++ b/packages/sdk/src/build/build-authorizations.ts @@ -6,28 +6,126 @@ import { } from "../interaction/interaction" import {Voucher} from "../encode/encode" +/** + * An object that contains all the information needed for FCL to sign a message with the user's signature. + * Note: These values are destructed from the payload object in the first argument of a signing function. + */ interface SignableMessage { + /** + * The encoded string which needs to be used to produce the signature. + */ message: string + /** + * The encoded string which needs to be used to produce the signature. + */ addr: string + /** + * The encoded string which needs to be used to produce the signature. + */ keyId: number | string + /** + * The encoded string which needs to be used to produce the signature. + */ roles: { + /** + * A Boolean representing if this signature to be produced for a proposer. + */ proposer: boolean + /** + * A Boolean representing if this signature to be produced for a authorizer. + */ authorizer: boolean + /** + * A Boolean representing if this signature to be produced for a payer. + */ payer: boolean } + /** + * The raw transactions information, can be used to create the message for additional safety and lack of trust in the supplied message. + */ voucher: Voucher } +/** + * The object that contains all the information needed by FCL to authorize a user's transaction. + */ interface SigningResult { + /** + * The address of the Flow Account this signature was produced for. + */ addr?: string + /** + * The keyId for which key was used to produce the signature. + */ keyId?: number | string + /** + * The hex encoded string representing the signature of the message. + */ signature: string } +/** + * A signing function consumes a payload and produces a signature for a transaction. + * This function is always async. + * Only write your own signing function if you are writing your own custom authorization function. + */ type SigningFn = ( signable?: SignableMessage ) => SigningResult | Promise +/** + * A utility builder to set the authorizations on a transaction. + * + * Authorizations define the accounts that are responsible for paying the transaction fees and providing signatures for the transaction. + * You can have multiple authorizers in a single transaction (multi-signature transactions). + * + * Read more about [transaction roles](https://docs.onflow.org/concepts/transaction-signing/) and [signing transactions](https://docs.onflow.org/concepts/accounts-and-keys/). + * + * @param ax An array of authorization functions that produce account authorization details + * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Single authorizer (most common case) + * await fcl.mutate({ + * cadence: ` + * transaction { + * prepare(acct: AuthAccount) { + * log("Hello from: ".concat(acct.address.toString())) + * } + * } + * `, + * authorizations: [fcl.authz] // Current user authorization + * }); + * + * // Multiple authorizers - both accounts must approve + * await fcl.mutate({ + * cadence: ` + * transaction { + * prepare(acct1: AuthAccount, acct2: AuthAccount) { + * log("Transaction signed by both accounts") + * } + * } + * `, + * authorizations: [userOneAuthz, userTwoAuthz] + * }); + * + * // Using builder pattern + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(acct: AuthAccount) { + * acct.save("Hello, World!", to: /storage/greeting) + * } + * } + * `, + * fcl.authorizations([fcl.authz]), + * fcl.proposer(fcl.authz), + * fcl.payer(fcl.authz), + * fcl.limit(100) + * ]); + */ export function authorizations(ax: Array = []) { return pipe( ax.map(authz => { @@ -38,6 +136,49 @@ export function authorizations(ax: Array = []) { ) } +/** + * Creates an authorization function for use in transactions. + * + * An authorization function must produce the information of the user that is going to sign and a signing function to use the information to produce a signature. + * + * Read more about [authorization functions](https://docs.onflow.org/fcl/reference/authorization-function/) and [transaction roles](https://docs.onflow.org/concepts/transaction-signing/). + * + * @param addr The address of the account that will sign the transaction + * @param signingFunction A function that produces signatures for the account + * @param keyId The index of the key to use for signing (optional) + * @param sequenceNum The sequence number for the account key (optional) + * @returns A partial interaction account object + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { ec as EC } from "elliptic"; + * + * // Create a signing function + * const signingFunction = ({ message }) => { + * // Your signing logic here + * return { + * addr: "0x123456789abcdef0", + * keyId: 0, + * signature: "your_signature_here" + * }; + * }; + * + * // Create authorization + * const authz = fcl.authorization( + * "0x123456789abcdef0", // account address + * signingFunction, // signing function + * 0, // key ID + * 42 // sequence number + * ); + * + * // Use in transaction + * await fcl.mutate({ + * cadence: `transaction { prepare(acct: AuthAccount) {} }`, + * proposer: authz, + * payer: authz, + * authorizations: [authz] + * }); + */ export function authorization( addr: string, signingFunction: SigningFn, diff --git a/packages/sdk/src/build/build-get-account.ts b/packages/sdk/src/build/build-get-account.ts index c45086dbd..7192bf437 100644 --- a/packages/sdk/src/build/build-get-account.ts +++ b/packages/sdk/src/build/build-get-account.ts @@ -7,9 +7,24 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get an account by address - * @param addr The address of the account to get + * A builder function that returns the interaction to get an account by address. + * + * Consider using the pre-built interaction 'fcl.account(address)' if you do not need to pair with any other builders. + * + * Account address is a unique account identifier. Be mindful about the '0x' prefix, you should use the prefix as a default representation but be careful and safely handle user inputs without the prefix. + * + * @param address Address of the user account with or without a prefix (both formats are supported) * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // somewhere in an async function + * // fcl.account is the same as this function + * const getAccount = async (address) => { + * const account = await fcl.send([fcl.getAccount(address)]).then(fcl.decode); + * return account; + * }; */ export function getAccount(addr: string): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-get-block-header.ts b/packages/sdk/src/build/build-get-block-header.ts index 7144a181c..8165e5b6b 100644 --- a/packages/sdk/src/build/build-get-block-header.ts +++ b/packages/sdk/src/build/build-get-block-header.ts @@ -6,9 +6,39 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get a block header - * @param isSealed Whether or not the block should be sealed + * A builder function that returns the interaction to get a block header. + * + * A block header contains metadata about a block without the full transaction details, making it more + * lightweight than fetching the entire block. This is useful when you only need block metadata like + * timestamp, height, parent hash, etc. + * + * Use with 'fcl.atBlockId()' and 'fcl.atBlockHeight()' when building the interaction to get headers for specific blocks. + * + * @param isSealed Block finality state, true for sealed blocks, false for finalized blocks, null for latest * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get latest sealed block header + * const sealedHeader = await fcl.send([ + * fcl.getBlockHeader(true) + * ]).then(fcl.decode); + * + * console.log("Block height:", sealedHeader.height); + * console.log("Block timestamp:", sealedHeader.timestamp); + * console.log("Parent block ID:", sealedHeader.parentId); + * + * // Get header for specific block + * const blockHeader = await fcl.send([ + * fcl.getBlockHeader(), + * fcl.atBlockHeight(12345) + * ]).then(fcl.decode); + * + * // Get latest finalized block header + * const finalizedHeader = await fcl.send([ + * fcl.getBlockHeader(false) + * ]).then(fcl.decode); */ export function getBlockHeader( isSealed: boolean | null = null diff --git a/packages/sdk/src/build/build-get-block.ts b/packages/sdk/src/build/build-get-block.ts index bcf9fda68..861aa3570 100644 --- a/packages/sdk/src/build/build-get-block.ts +++ b/packages/sdk/src/build/build-get-block.ts @@ -6,9 +6,25 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get the latest block - * @param isSealed Whether or not the block should be sealed + * A builder function that returns the interaction to get the latest block. + * + * Use with 'fcl.atBlockId()' and 'fcl.atBlockHeight()' when building the interaction to get information for older blocks. + * + * Consider using the pre-built interaction 'fcl.block(options)' if you do not need to pair with any other builders. + * + * Block ID is SHA3-256 hash of the entire block payload. This hash is stored as an ID field on any block response object (ie. response from 'GetLatestBlock'). + * + * Block height expresses the height of the block on the chain. The latest block height increases by one for every valid block produced. + * + * @param isSealed If the latest block should be sealed or not. See block states * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * const latestSealedBlock = await fcl.send([ + * fcl.getBlock(true) // isSealed = true + * ]).then(fcl.decode); */ export function getBlock( isSealed: boolean | null = null diff --git a/packages/sdk/src/build/build-get-collection.ts b/packages/sdk/src/build/build-get-collection.ts index 1f8d0703f..518a20045 100644 --- a/packages/sdk/src/build/build-get-collection.ts +++ b/packages/sdk/src/build/build-get-collection.ts @@ -5,9 +5,36 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get a collection by ID - * @param id The ID of the collection to get + * A builder function that returns a collection containing a list of transaction IDs by its collection ID. + * + * A collection is a batch of transactions that have been included in a block. Each collection has a unique ID + * which is the SHA3-256 hash of the collection payload. Collections are used to group related transactions + * together for more efficient processing by the network. + * + * The collection ID provided must be from the current spork. Collections from past sporks are currently unavailable. + * + * @param collectionID The ID of the collection to retrieve * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get a collection and see what transactions it contains + * const collection = await fcl.send([ + * fcl.getCollection("cccdb0c67d015dc7f6444e8f62a3244ed650215ed66b90603006c70c5ef1f6e5") + * ]).then(fcl.decode); + * + * console.log("Collection ID:", collection.id); + * console.log("Transaction IDs:", collection.transactionIds); + * console.log("Total transactions:", collection.transactionIds.length); + * + * // Process each transaction in the collection + * for (const txId of collection.transactionIds) { + * const transaction = await fcl.send([ + * fcl.getTransaction(txId) + * ]).then(fcl.decode); + * console.log("Transaction:", transaction); + * } */ export function getCollection(id: string | null = null): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-get-events-at-block-height-range.ts b/packages/sdk/src/build/build-get-events-at-block-height-range.ts index 85a019597..b71b97415 100644 --- a/packages/sdk/src/build/build-get-events-at-block-height-range.ts +++ b/packages/sdk/src/build/build-get-events-at-block-height-range.ts @@ -6,11 +6,36 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get events at a block height range + * A builder function that returns all instances of a particular event (by name) within a height range. + * + * The block range provided must be from the current spork. + * + * The block range provided must be 250 blocks or lower per request. + * + * Event type is a string that follow a standard format: A.{AccountAddress}.{ContractName}.{EventName} + * + * Please read more about [events in the documentation](https://docs.onflow.org/cadence/language/events/). + * + * Block height range expresses the height of the start and end block in the chain. + * * @param eventType The type of event to get - * @param startHeight The start height of the block range - * @param endHeight The end height of the block range + * @param startHeight The height of the block to start looking for events (inclusive) + * @param endHeight The height of the block to stop looking for events (inclusive) * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get events at block height range + * await fcl + * .send([ + * fcl.getEventsAtBlockHeightRange( + * "A.7e60df042a9c0868.FlowToken.TokensWithdrawn", // event name + * 35580624, // block to start looking for events at + * 35580624 // block to stop looking for events at + * ), + * ]) + * .then(fcl.decode); */ export function getEventsAtBlockHeightRange( eventType: string, diff --git a/packages/sdk/src/build/build-get-events-at-block-ids.ts b/packages/sdk/src/build/build-get-events-at-block-ids.ts index 1b2bc02d1..2598f0118 100644 --- a/packages/sdk/src/build/build-get-events-at-block-ids.ts +++ b/packages/sdk/src/build/build-get-events-at-block-ids.ts @@ -6,10 +6,27 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get events at specific block IDs + * A builder function that returns all instances of a particular event (by name) within a set of blocks, specified by block ids. + * + * The block range provided must be from the current spork. + * + * Event type is a string that follow a standard format: A.{AccountAddress}.{ContractName}.{EventName} + * + * Please read more about [events in the documentation](https://docs.onflow.org/cadence/language/events/). + * * @param eventType The type of event to get - * @param blockIds The block IDs to get events from + * @param blockIds The ids of the blocks to scan for events * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * const events = await fcl.send([ + * fcl.getEventsAtBlockIds("A.7e60df042a9c0868.FlowToken.TokensWithdrawn", [ + * "c4f239d49e96d1e5fbcf1f31027a6e582e8c03fcd9954177b7723fdb03d938c7", + * "5dbaa85922eb194a3dc463c946cc01c866f2ff2b88f3e59e21c0d8d00113273f" + * ]) + * ]).then(fcl.decode); */ export function getEventsAtBlockIds( eventType: string, diff --git a/packages/sdk/src/build/build-get-events.ts b/packages/sdk/src/build/build-get-events.ts index c70f16d44..b4d5c601f 100644 --- a/packages/sdk/src/build/build-get-events.ts +++ b/packages/sdk/src/build/build-get-events.ts @@ -6,11 +6,29 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get events - * @param eventType The type of event to get - * @param start The start block ID or height - * @param end The end block ID or height + * A builder function that returns the interaction to get events. + * + * Events are emitted by Cadence code during transaction execution and provide insights into what happened during execution. + * This function queries for events of a specific type within a range of block heights. + * + * @param eventType The type of event to get (e.g., "A.1654653399040a61.FlowToken.TokensWithdrawn") + * @param start The start block height to query from + * @param end The end block height to query to * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get FlowToken transfer events from blocks 1000 to 2000 + * const events = await fcl.send([ + * fcl.getEvents("A.1654653399040a61.FlowToken.TokensDeposited", 1000, 2000) + * ]).then(fcl.decode); + * + * console.log("Found events:", events.length); + * events.forEach(event => { + * console.log("Event data:", event.data); + * console.log("Transaction ID:", event.transactionId); + * }); */ export function getEvents( eventType: string, diff --git a/packages/sdk/src/build/build-get-latest-block.ts b/packages/sdk/src/build/build-get-latest-block.ts index 7414cc1e0..483dee325 100644 --- a/packages/sdk/src/build/build-get-latest-block.ts +++ b/packages/sdk/src/build/build-get-latest-block.ts @@ -7,9 +7,32 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get the latest block - * @param isSealed Whether or not the block should be sealed + * A builder function that returns the interaction to get the latest block + * + * This function creates an interaction to retrieve the latest block from the Flow blockchain. + * You can specify whether to get the latest executed block (soft finality) or the latest sealed block (hard finality). + * + * @param isSealed Whether or not the block should be sealed (defaults to false for executed blocks) * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get the latest executed block (soft finality) + * const latestBlock = await fcl.send([fcl.getLatestBlock()]).then(fcl.decode); + * console.log("Latest block height:", latestBlock.height); + * console.log("Block ID:", latestBlock.id); + * console.log("Block timestamp:", latestBlock.timestamp); + * + * // Get the latest sealed block (hard finality) + * const sealedBlock = await fcl.send([fcl.getLatestBlock(true)]).then(fcl.decode); + * console.log("Latest sealed block height:", sealedBlock.height); + * + * // Use in combination with other builders + * const blockInfo = await fcl.send([ + * fcl.getLatestBlock(), + * // Additional builders can be added here + * ]).then(fcl.decode); */ export function getLatestBlock( isSealed: boolean = false @@ -24,7 +47,7 @@ export function getLatestBlock( return pipe([ makeGetBlock, ix => { - ix.block.isSealed = isSealed + ix.block.isSealed = isSealed ?? false return Ok(ix) }, ]) diff --git a/packages/sdk/src/build/build-get-network-parameters.ts b/packages/sdk/src/build/build-get-network-parameters.ts index 38dc74994..b9d88796b 100644 --- a/packages/sdk/src/build/build-get-network-parameters.ts +++ b/packages/sdk/src/build/build-get-network-parameters.ts @@ -6,8 +6,31 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get network parameters + * A builder function that returns the interaction to get network parameters. + * + * Network parameters contain important configuration information about the Flow network, + * including the chain ID, which is essential for signing transactions correctly. + * This information is crucial for ensuring transactions are submitted to the correct network. + * * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get network parameters to verify chain ID + * const params = await fcl.send([ + * fcl.getNetworkParameters() + * ]).then(fcl.decode); + * + * console.log("Chain ID:", params.chainId); + * console.log("Network:", params.name); + * + * // Use this to verify you're connected to the right network + * if (params.chainId === "flow-mainnet") { + * console.log("Connected to Flow Mainnet"); + * } else if (params.chainId === "flow-testnet") { + * console.log("Connected to Flow Testnet"); + * } */ export function getNetworkParameters(): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-get-node-version-info.ts b/packages/sdk/src/build/build-get-node-version-info.ts index e9c8aed4c..b9ab5d292 100644 --- a/packages/sdk/src/build/build-get-node-version-info.ts +++ b/packages/sdk/src/build/build-get-node-version-info.ts @@ -6,8 +6,32 @@ import { } from "../interaction/interaction" /** - * @description A builder function for the Get Node Version Info interaction - * @returns An interaction object + * A builder function for the Get Node Version Info interaction. + * + * Creates an interaction to retrieve version information from the connected Flow Access Node. + * This includes details about the node's software version, protocol version, and spork information. + * + * Consider using the pre-built interaction 'fcl.nodeVersionInfo()' if you do not need to pair with any other builders. + * + * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get node version information using builder + * const versionInfo = await fcl.send([ + * fcl.getNodeVersionInfo() + * ]).then(fcl.decode); + * + * console.log("Node version:", versionInfo.semver); + * console.log("Protocol version:", versionInfo.protocol_version); + * console.log("Spork ID:", versionInfo.spork_id); + * + * // Use with other builders if needed + * const interaction = await fcl.build([ + * fcl.getNodeVersionInfo() + * // other builders can be added here + * ]); */ export function getNodeVersionInfo(): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-get-transaction-status.ts b/packages/sdk/src/build/build-get-transaction-status.ts index 555854140..9c17eb71e 100644 --- a/packages/sdk/src/build/build-get-transaction-status.ts +++ b/packages/sdk/src/build/build-get-transaction-status.ts @@ -6,10 +6,21 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the status of transaction - * NOTE: The transactionID provided must be from the current spork. - * @param transactionId The id of the transaction to get status - * @returns An interaction object + * A builder function that returns the status of transaction. + * + * The transaction id provided must be from the current spork. + * + * Consider using 'fcl.tx(id)' instead of calling this method directly for real-time transaction monitoring. + * + * @param transactionId The id of the transaction to get the status of + * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * const status = await fcl.send([ + * fcl.getTransactionStatus("9dda5f281897389b99f103a1c6b180eec9dac870de846449a302103ce38453f3") + * ]).then(fcl.decode); */ export function getTransactionStatus( transactionId: string diff --git a/packages/sdk/src/build/build-get-transaction.ts b/packages/sdk/src/build/build-get-transaction.ts index f467ee236..cef671ca5 100644 --- a/packages/sdk/src/build/build-get-transaction.ts +++ b/packages/sdk/src/build/build-get-transaction.ts @@ -6,9 +6,24 @@ import { } from "../interaction/interaction" /** - * @description A builder function that returns the interaction to get a transaction by ID - * @param id The ID of the transaction to get + * A builder function that returns the interaction to get a transaction by id. + * + * Transaction id is a hash of the encoded transaction payload and can be calculated before submitting the transaction to the network. + * Transaction status represents the state of a transaction in the blockchain. Status can change until it is finalized. + * + * The transaction id provided must be from the current spork. + * + * Consider using 'fcl.tx(id).onceExecuted()' instead of calling this method directly for real-time transaction monitoring. + * + * @param transactionId The id of the transaction to get * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * const tx = await fcl.send([ + * fcl.getTransaction("9dda5f281897389b99f103a1c6b180eec9dac870de846449a302103ce38453f3") + * ]).then(fcl.decode); */ export function getTransaction(id: string): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-limit.ts b/packages/sdk/src/build/build-limit.ts index 7c9fa5576..943f66d69 100644 --- a/packages/sdk/src/build/build-limit.ts +++ b/packages/sdk/src/build/build-limit.ts @@ -1,9 +1,41 @@ import {InteractionBuilderFn} from "../interaction/interaction" /** - * @description A builder function that sets the compute limit for a transaction - * @param limit The compute limit to set + * A utility builder to set the compute limit on a transaction. + * + * The compute limit is the maximum amount of computation that can be performed during transaction execution. + * Setting an appropriate compute limit helps prevent infinite loops and ensures predictable transaction costs. + * + * Read more about [computation cost](https://docs.onflow.org/concepts/fees/#computation-cost) and [transaction fees](https://docs.onflow.org/concepts/fees/). + * + * @param limit The maximum amount of computation for the transaction * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * await fcl.mutate({ + * cadence: ` + * transaction { + * prepare(account: AuthAccount) { + * // Complex transaction logic here + * } + * } + * `, + * limit: 1000 // Set compute limit to 1000 + * }); + * + * // Using builder pattern + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(account: AuthAccount) { + * // Transaction logic + * } + * } + * `, + * fcl.limit(9999) // Set higher limit for complex operations + * ]); */ export function limit(limit: number): InteractionBuilderFn { return ix => { diff --git a/packages/sdk/src/build/build-payer.ts b/packages/sdk/src/build/build-payer.ts index 7380bd2c7..e936ca2b8 100644 --- a/packages/sdk/src/build/build-payer.ts +++ b/packages/sdk/src/build/build-payer.ts @@ -6,9 +6,58 @@ import { } from "../interaction/interaction" /** - * @description A builder function that adds payer account(s) to a transaction - * @param ax An account address or array of account addresses - * @returns A function that takes an interaction and returns a new interaction with the payer(s) added + * A builder function that adds payer account(s) to a transaction. + * + * Every transaction requires at least one payer. + * + * The payer is the account that pays the transaction fee for executing the transaction on the network. + * The payer account must have sufficient Flow tokens to cover the transaction fees. + * + * Read more about [transaction roles](https://docs.onflow.org/concepts/transaction-signing/#payer) and [transaction fees](https://docs.onflow.org/concepts/fees/). + * + * @param ax An account address or an array of account addresses + * @returns A function that takes an interaction object and returns a new interaction object with the payer(s) added + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Using current user as payer (most common case) + * await fcl.mutate({ + * cadence: ` + * transaction { + * prepare(acct: AuthAccount) { + * log("Transaction fees paid by: ".concat(acct.address.toString())) + * } + * } + * `, + * payer: fcl.authz // Current user as payer + * }); + * + * // Using custom payer with builder pattern + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(acct: AuthAccount) { + * // Transaction logic + * } + * } + * `, + * fcl.proposer(fcl.authz), // Current user as proposer + * fcl.authorizations([fcl.authz]), // Current user as authorizer + * fcl.payer(customPayerAuthz) // Custom payer pays fees + * ]); + * + * // Multiple payers (advanced use case) + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(acct: AuthAccount) { + * // Transaction logic + * } + * } + * `, + * fcl.payer([payerAuthz1, payerAuthz2]) // Multiple payers split fees + * ]); */ export function payer(ax: AccountAuthorization[] = []) { if (!Array.isArray(ax)) ax = [ax] diff --git a/packages/sdk/src/build/build-ping.ts b/packages/sdk/src/build/build-ping.ts index 635227a75..6a68336ca 100644 --- a/packages/sdk/src/build/build-ping.ts +++ b/packages/sdk/src/build/build-ping.ts @@ -1,8 +1,35 @@ import {makePing, InteractionBuilderFn} from "../interaction/interaction" /** - * @description A builder function that creates a ping interaction + * A builder function that creates a ping interaction to test connectivity to the Flow Access Node. + * + * The ping interaction is a simple way to test if the Flow Access Node is reachable and responding. This is useful for health checks, connectivity testing, and debugging network issues. + * * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Simple ping to test connectivity + * try { + * const response = await fcl.send([fcl.ping()]); + * console.log("Access Node is reachable"); + * } catch (error) { + * console.error("Access Node is not reachable:", error); + * } + * + * // Use ping for health checks + * const healthCheck = async () => { + * try { + * await fcl.send([fcl.ping()]); + * return { status: "healthy", timestamp: new Date().toISOString() }; + * } catch (error) { + * return { status: "unhealthy", error: error.message, timestamp: new Date().toISOString() }; + * } + * }; + * + * const health = await healthCheck(); + * console.log("Health status:", health); */ export function ping(): InteractionBuilderFn { return makePing diff --git a/packages/sdk/src/build/build-proposer.ts b/packages/sdk/src/build/build-proposer.ts index 09230279d..c94d247f4 100644 --- a/packages/sdk/src/build/build-proposer.ts +++ b/packages/sdk/src/build/build-proposer.ts @@ -1,6 +1,49 @@ import {TransactionRole} from "@onflow/typedefs" import {AccountAuthorization, prepAccount} from "../interaction/interaction" +/** + * A builder function that adds the proposer to a transaction. + * + * The proposer is responsible for providing the proposal key and paying the network fee for the transaction. + * The proposer key is used to specify the sequence number and prevent replay attacks. + * + * Every transaction requires exactly one proposer. + * + * Read more about [transaction roles](https://docs.onflow.org/concepts/transaction-signing/#proposer) and [signing transactions](https://docs.onflow.org/concepts/accounts-and-keys/). + * + * @param authz The authorization object for the proposer + * @returns A function that takes an interaction object and returns a new interaction object with the proposer added + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Using the current user as proposer + * await fcl.mutate({ + * cadence: ` + * transaction { + * prepare(account: AuthAccount) { + * log("Hello from proposer!") + * } + * } + * `, + * proposer: fcl.authz + * }); + * + * // Using builder pattern + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(account: AuthAccount) { + * log("Transaction executed") + * } + * } + * `, + * fcl.proposer(proposerAuthz), + * fcl.payer(payerAuthz), + * fcl.authorizations([authorizerAuthz]), + * fcl.limit(100) + * ]); + */ export function proposer(authz: AccountAuthorization) { return prepAccount(authz, { role: TransactionRole.PROPOSER, diff --git a/packages/sdk/src/build/build-ref.ts b/packages/sdk/src/build/build-ref.ts index bca557dac..343c8e481 100644 --- a/packages/sdk/src/build/build-ref.ts +++ b/packages/sdk/src/build/build-ref.ts @@ -1,9 +1,35 @@ import {pipe, Ok, InteractionBuilderFn} from "../interaction/interaction" /** - * @description A builder function that sets the reference block for a transaction + * A builder function that sets the reference block for a transaction. + * + * The reference block specifies an expiration window (measured in blocks) during which a transaction is considered valid by the network. + * A transaction will be rejected if it is submitted past its expiry block. Flow calculates transaction expiry using the reference block field. + * * @param refBlock The reference block ID * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Set specific reference block for transaction + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(account: AuthAccount) { + * log("Transaction with custom reference block") + * } + * } + * `, + * fcl.ref("a1b2c3d4e5f6789..."), // Custom reference block ID + * fcl.proposer(fcl.authz), + * fcl.payer(fcl.authz), + * fcl.authorizations([fcl.authz]), + * fcl.limit(100) + * ]); + * + * // Usually, you don't need to set reference block manually + * // as FCL will automatically set it to the latest block */ export function ref(refBlock: string): InteractionBuilderFn { return pipe([ diff --git a/packages/sdk/src/build/build-script.ts b/packages/sdk/src/build/build-script.ts index d51c73c6b..748ec3be8 100644 --- a/packages/sdk/src/build/build-script.ts +++ b/packages/sdk/src/build/build-script.ts @@ -7,8 +7,37 @@ import { import {template} from "@onflow/util-template" /** - * @description A builder function that creates a script interaction + * A builder function that creates a script interaction. Scripts allow you to write arbitrary non-mutating Cadence code on the Flow blockchain and return data. + * + * You can learn more about [Cadence here](https://cadence-lang.org/docs/language), but we are now only interested in executing the script code and getting back the data. + * + * We can execute a script using the latest state of the Flow blockchain or we can choose to execute the script at a specific time in history defined by a block height or block ID. + * + * Block ID is SHA3-256 hash of the entire block payload, but you can get that value from the block response properties. + * + * Block height expresses the height of the block in the chain. + * + * @param args The arguments to pass to the template * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * const result = await fcl.query({ + * cadence: ` + * access(all) fun main(a: Int, b: Int, addr: Address): Int { + * log(addr) + * return a + b + * } + * `, + * args: (arg, t) => [ + * arg(7, t.Int), // a: Int + * arg(6, t.Int), // b: Int + * arg("0xba1132bc08f82fe2", t.Address), // addr: Address + * ], + * }); + * + * console.log(result); // 13 */ export function script( ...args: [ diff --git a/packages/sdk/src/build/build-subscribe-events.ts b/packages/sdk/src/build/build-subscribe-events.ts index a4648aef2..fcfce799f 100644 --- a/packages/sdk/src/build/build-subscribe-events.ts +++ b/packages/sdk/src/build/build-subscribe-events.ts @@ -8,9 +8,47 @@ import { import {EventFilter, Interaction} from "@onflow/typedefs" /** - * @description Subscribe to events with the given filter & parameters - * @param filter The filter to subscribe to events with + * Subscribe to events with the given filter and parameters. + * + * Creates a subscription to listen for real-time events from the Flow blockchain. This function configures + * the subscription parameters for filtering specific events based on type, addresses, contracts, and other criteria. + * + * Events are emitted by Cadence code during transaction execution and provide insights into what happened. + * Subscriptions allow you to listen for these events in real-time without polling. + * + * @param filter The filter configuration for the event subscription + * @param filter.startBlockId Optional block ID to start subscription from + * @param filter.startHeight Optional block height to start subscription from + * @param filter.eventTypes Array of event types to filter for + * @param filter.addresses Array of account addresses to filter events from + * @param filter.contracts Array of contract names to filter events from + * @param filter.heartbeatInterval Interval for heartbeat messages in milliseconds * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Subscribe to FlowToken transfer events + * const subscription = await fcl.send([ + * fcl.subscribeEvents({ + * eventTypes: [ + * "A.1654653399040a61.FlowToken.TokensWithdrawn", + * "A.1654653399040a61.FlowToken.TokensDeposited" + * ], + * startHeight: 1000000, // Start from specific block height + * heartbeatInterval: 3000 // 3 second heartbeat + * }) + * ]); + * + * // Subscribe to events from specific contracts + * const contractSubscription = await fcl.send([ + * fcl.subscribeEvents({ + * contracts: ["FlowToken", "FungibleToken"], + * addresses: ["0x1654653399040a61"] + * }) + * ]); + * + * // Handle the subscription data elsewhere using fcl.subscribe() */ export function subscribeEvents({ startBlockId, diff --git a/packages/sdk/src/build/build-transaction.ts b/packages/sdk/src/build/build-transaction.ts index 3af1793ed..597842e0a 100644 --- a/packages/sdk/src/build/build-transaction.ts +++ b/packages/sdk/src/build/build-transaction.ts @@ -11,9 +11,57 @@ const DEFAULT_SCRIPT_ACCOUNTS: string[] = [] const DEFAULT_REF: any = null /** - * @description A template builder to use a Cadence transaction for an interaction - * @param args The arguments to pass + * A template builder to use a Cadence transaction for an interaction. FCL "mutate" does the work of building, signing, and sending a transaction behind the scenes. + * + * Flow supports great flexibility when it comes to transaction signing, we can define multiple authorizers (multi-sig transactions) and have different payer account than proposer. + * + * @param args The arguments to pass to the template * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl" + * + * // Basic transaction usage + * await fcl.mutate({ + * cadence: ` + * transaction(a: Int) { + * prepare(acct: &Account) { + * log(acct) + * log(a) + * } + * } + * `, + * args: (arg, t) => [ + * arg(6, t.Int) + * ], + * limit: 50 + * }) + * + * // Single party, single signature + * // Proposer, payer and authorizer are the same account + * await fcl.mutate({ + * cadence: ` + * transaction { + * prepare(acct: &Account) {} + * } + * `, + * authz: currentUser, // Optional. Will default to currentUser if not provided. + * limit: 50, + * }) + * + * // Multiple parties + * // Proposer and authorizer are the same account, but different payer + * await fcl.mutate({ + * cadence: ` + * transaction { + * prepare(acct: &Account) {} + * } + * `, + * proposer: authzFn, + * payer: authzTwoFn, + * authorizations: [authzFn], + * limit: 50, + * }) */ export function transaction( ...args: [string | TemplateStringsArray, ...any[]] diff --git a/packages/sdk/src/build/build-validator.ts b/packages/sdk/src/build/build-validator.ts index 34f4c6124..e13926966 100644 --- a/packages/sdk/src/build/build-validator.ts +++ b/packages/sdk/src/build/build-validator.ts @@ -1,9 +1,37 @@ import {update, InteractionBuilderFn} from "../interaction/interaction" /** - * @description A builder function that adds a validator to a transaction - * @param cb The validator function + * A builder function that adds a validator to a transaction. + * + * Validators are functions that run during transaction building to check for invalid configurations or parameters. + * They help catch errors early before submitting transactions to the network, preventing failed transactions + * and wasted compute costs. + * + * @param cb The validator function that takes an interaction and returns it (or throws an error if invalid) * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Custom validator to ensure account has sufficient balance + * const validateBalance = (ix) => { + * if (ix.message.computeLimit > 1000) { + * throw new Error("Compute limit too high for this account"); + * } + * return ix; + * }; + * + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(account: AuthAccount) { + * // Transaction logic + * } + * } + * `, + * fcl.validator(validateBalance), + * fcl.limit(500) // This will pass validation + * ]); */ export function validator(cb: Function): InteractionBuilderFn { return update("ix.validators", (validators: Function | Function[]) => diff --git a/packages/sdk/src/build/build-voucher-intercept.ts b/packages/sdk/src/build/build-voucher-intercept.ts index b8af89832..bad1a4678 100644 --- a/packages/sdk/src/build/build-voucher-intercept.ts +++ b/packages/sdk/src/build/build-voucher-intercept.ts @@ -4,9 +4,39 @@ import {Voucher} from "../encode/encode" type VoucherInterceptFn = (voucher: Voucher) => any | Promise /** - * @description A builder function that intercepts and modifies a voucher - * @param fn The function to intercept and modify the voucher + * A builder function that intercepts and modifies a voucher. + * + * This function is useful for debugging, logging, or making modifications to + * the transaction data. The voucher contains all the transaction details in their final form. + * + * @param fn The function to intercept and potentially modify the voucher * @returns A function that processes an interaction object + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Intercept voucher for logging + * await fcl.send([ + * fcl.transaction` + * transaction { + * prepare(account: AuthAccount) { + * log("Transaction executed") + * } + * } + * `, + * fcl.voucherIntercept((voucher) => { + * console.log("Voucher details:", { + * cadence: voucher.cadence, + * proposalKey: voucher.proposalKey, + * payer: voucher.payer, + * authorizers: voucher.authorizers, + * computeLimit: voucher.computeLimit + * }); + * }), + * fcl.proposer(fcl.authz), + * fcl.payer(fcl.authz), + * fcl.authorizations([fcl.authz]) + * ]); */ export function voucherIntercept(fn: VoucherInterceptFn): InteractionBuilderFn { return put("ix.voucher-intercept", fn) diff --git a/packages/sdk/src/build/build.ts b/packages/sdk/src/build/build.ts index 1efe94916..f2eb6846c 100644 --- a/packages/sdk/src/build/build.ts +++ b/packages/sdk/src/build/build.ts @@ -6,9 +6,46 @@ import { import {Interaction} from "@onflow/typedefs" /** - * @description A builder function that creates an interaction + * A builder function that creates an interaction from an array of builder functions. + * + * The build function takes an array of builder functions and applies them to create a complete interaction object. This is the foundation for constructing all interactions in Flow, whether they're scripts, transactions, or queries. + * + * Each builder function modifies specific parts of the interaction object, such as adding Cadence code, arguments, authorization details, or other configuration. + * * @param fns The functions to apply to the interaction * @returns A promise of an interaction + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Build a script interaction + * const scriptInteraction = await fcl.build([ + * fcl.script` + * access(all) fun main(a: Int, b: Int): Int { + * return a + b + * } + * `, + * fcl.args([ + * fcl.arg(1, fcl.t.Int), + * fcl.arg(2, fcl.t.Int) + * ]) + * ]); + * + * // Build a transaction interaction + * const txInteraction = await fcl.build([ + * fcl.transaction` + * transaction(name: String) { + * prepare(account: AuthAccount) { + * log("Hello, " + name) + * } + * } + * `, + * fcl.args([fcl.arg("World", fcl.t.String)]), + * fcl.proposer(proposerAuthz), + * fcl.payer(payerAuthz), + * fcl.authorizations([authorizerAuthz]), + * fcl.limit(100) + * ]); */ export function build( fns: (InteractionBuilderFn | false)[] = [] diff --git a/packages/sdk/src/decode/decode-stream.ts b/packages/sdk/src/decode/decode-stream.ts index 06bc1d496..e18604c4d 100644 --- a/packages/sdk/src/decode/decode-stream.ts +++ b/packages/sdk/src/decode/decode-stream.ts @@ -7,8 +7,54 @@ type DecodeResponseFn = ( ) => Promise /** - * Pipes a generic stream of data into a granular stream of decoded data - * The data is decoded per channel and emitted in order + * Pipes a generic stream of data into a granular stream of decoded data. + * + * The data is decoded per channel and emitted in order. This function is particularly useful + * for handling streaming responses from Flow Access API, such as event subscriptions or + * real-time block updates. It ensures that data is properly decoded and emitted in the + * correct order while maintaining the stream's event-driven nature. + * + * All topics for a given message will be emitted synchronously before moving on to the next + * message. The internal queue ensures that data is emitted in order and avoids race conditions + * when decoding. + * + * @param stream The raw stream connection to decode + * @param decodeResponse Function to decode response data + * @param customDecoders Optional custom decoders for specific data types + * @returns A new stream connection with decoded data + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Create a subscription stream + * const rawStream = await fcl.send([ + * fcl.subscribeEvents({ + * eventTypes: ["flow.AccountCreated"], + * startHeight: 0 + * }) + * ]); + * + * // Decode the stream data + * const decodedStream = fcl.decodeStream( + * rawStream, + * fcl.decodeResponse, + * {} + * ); + * + * // Listen for decoded events + * decodedStream.on("events", (events) => { + * events.forEach(event => { + * console.log("Decoded event:", event); + * }); + * }); + * + * decodedStream.on("error", (error) => { + * console.error("Stream error:", error); + * }); + * + * decodedStream.on("close", () => { + * console.log("Stream closed"); + * }); */ export const decodeStream = ( stream: StreamConnection<{data: any}>, diff --git a/packages/sdk/src/decode/decode.ts b/packages/sdk/src/decode/decode.ts index 040d5bc67..fb0977d9f 100644 --- a/packages/sdk/src/decode/decode.ts +++ b/packages/sdk/src/decode/decode.ts @@ -285,6 +285,13 @@ export const decode = async ( return recurseDecode(decodeInstructions, decoders, stack) } +/** + * Decodes a response from Flow into JSON + * + * @param response The response object from Flow + * @param customDecoders An object of custom decoders + * @returns The decoded response + */ export const decodeResponse = async ( response: FlowResponse, customDecoders: DecoderMap = {} diff --git a/packages/sdk/src/decode/sdk-decode.ts b/packages/sdk/src/decode/sdk-decode.ts index 58c103052..ece0d2cc3 100644 --- a/packages/sdk/src/decode/sdk-decode.ts +++ b/packages/sdk/src/decode/sdk-decode.ts @@ -1,6 +1,43 @@ import {config} from "@onflow/config" import {decodeResponse} from "./decode" +/** + * Decodes the response from 'fcl.send()' into the appropriate JSON representation of any values returned from Cadence code. + * + * The response from Flow contains encoded values that need to be decoded into JavaScript types. This function handles that conversion, including complex types like structs, arrays, and dictionaries. + * + * @param response Should be the response returned from 'fcl.send([...])' + * @returns A JSON representation of the raw string response depending on the cadence code executed. The return value can be a single value and type or an object with multiple types. + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Simple script to add 2 numbers + * const response = await fcl.send([ + * fcl.script` + * access(all) fun main(int1: Int, int2: Int): Int { + * return int1 + int2 + * } + * `, + * fcl.args([fcl.arg(1, fcl.t.Int), fcl.arg(2, fcl.t.Int)]) + * ]); + * + * const decoded = await fcl.decode(response); + * console.log(decoded); // 3 + * console.log(typeof decoded); // "number" + * + * // Complex return types + * const complexResponse = await fcl.send([ + * fcl.script` + * access(all) fun main(): {String: Int} { + * return {"foo": 1, "bar": 2} + * } + * ` + * ]); + * + * const complexDecoded = await fcl.decode(complexResponse); + * console.log(complexDecoded); // {foo: 1, bar: 2} + */ export async function decode(response: any): Promise { const decodersFromConfig = await config().where(/^decoder\./) const decoders = Object.entries(decodersFromConfig).map( diff --git a/packages/sdk/src/encode/encode.ts b/packages/sdk/src/encode/encode.ts index 3c9f31408..a767dd0d7 100644 --- a/packages/sdk/src/encode/encode.ts +++ b/packages/sdk/src/encode/encode.ts @@ -2,10 +2,124 @@ import {SHA3} from "sha3" import {encode, Buffer, EncodeInput} from "@onflow/rlp" import {sansPrefix} from "@onflow/util-address" +/** + * Encodes a transaction payload for signing. + * + * This function takes a transaction object and encodes it into a format suitable for signing. + * The encoded payload contains all the transaction details except for the signatures. + * + * @param tx The transaction object to encode + * @returns A hex-encoded string representing the transaction payload + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { encodeTransactionPayload } from "@onflow/sdk" + * + * // Build a transaction + * const transaction = await fcl.build([ + * fcl.transaction` + * transaction(amount: UFix64) { + * prepare(account: AuthAccount) { + * log("Transferring: ".concat(amount.toString())) + * } + * } + * `, + * fcl.args([fcl.arg("10.0", fcl.t.UFix64)]), + * fcl.proposer(proposerAuthz), + * fcl.payer(payerAuthz), + * fcl.authorizations([authorizerAuthz]), + * fcl.limit(100) + * ]); + * + * // Encode the transaction payload for signing + * const encodedPayload = encodeTransactionPayload(transaction); + * console.log("Encoded payload:", encodedPayload); + * // Returns a hex string like "f90145b90140..." + */ export const encodeTransactionPayload = (tx: Transaction) => prependTransactionDomainTag(rlpEncode(preparePayload(tx))) + +/** + * Encodes a complete transaction envelope including payload and signatures. + * + * This function encodes the full transaction including both the payload and all signatures. + * This is the final step before submitting a transaction to the Flow network. + * + * @param tx The transaction object to encode + * @returns A hex-encoded string representing the complete transaction envelope + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { encodeTransactionEnvelope } from "@onflow/sdk" + * + * // Assuming you have a fully built and signed transaction + * const signedTransaction = await fcl.build([ + * fcl.transaction` + * transaction { + * prepare(account: AuthAccount) { + * log("Hello, Flow!") + * } + * } + * `, + * fcl.proposer(authz), + * fcl.payer(authz), + * fcl.authorizations([authz]), + * fcl.limit(100) + * ]); + * + * // Add signatures to the transaction (this is usually done automatically) + * // signedTransaction.payloadSigs = [...]; + * // signedTransaction.envelopeSigs = [...]; + * + * // Encode the complete transaction envelope + * const encodedEnvelope = encodeTransactionEnvelope(signedTransaction); + * console.log("Encoded envelope:", encodedEnvelope); + * // Returns a hex string ready for network submission + */ export const encodeTransactionEnvelope = (tx: Transaction) => prependTransactionDomainTag(rlpEncode(prepareEnvelope(tx))) + +/** + * Encodes a transaction ID from a voucher by computing its hash. + * + * A voucher is an intermediary object that contains transaction details before final encoding. + * This function computes the transaction ID that would result from submitting the transaction. + * + * @param voucher The voucher object containing transaction details + * @returns A hex-encoded string representing the transaction ID + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { encodeTxIdFromVoucher } from "@onflow/sdk" + * + * // Create a voucher (usually done internally by FCL) + * const voucher = { + * cadence: ` + * transaction { + * prepare(account: AuthAccount) { + * log("Hello") + * } + * } + * `, + * arguments: [], + * refBlock: "abc123...", + * computeLimit: 100, + * proposalKey: { + * address: "0x123456789abcdef0", + * keyId: 0, + * sequenceNum: 42 + * }, + * payer: "0x123456789abcdef0", + * authorizers: ["0x123456789abcdef0"], + * payloadSigs: [], + * envelopeSigs: [] + * }; + * + * // Calculate the transaction ID + * const txId = encodeTxIdFromVoucher(voucher); + * console.log("Transaction ID:", txId); + * // Returns a transaction ID that can be used to track the transaction + */ export const encodeTxIdFromVoucher = (voucher: Voucher) => sha3_256(rlpEncode(prepareVoucher(voucher))) diff --git a/packages/sdk/src/interaction/interaction.ts b/packages/sdk/src/interaction/interaction.ts index 45d49cd1d..7d1b9e640 100644 --- a/packages/sdk/src/interaction/interaction.ts +++ b/packages/sdk/src/interaction/interaction.ts @@ -108,9 +108,19 @@ const IX = `{ const KEYS = new Set(Object.keys(JSON.parse(IX) as Interaction)) +/** + * Creates a new interaction object with default values. + * + * @returns A new interaction object initialized with default values + */ export const initInteraction = (): Interaction => JSON.parse(IX) + /** - * @deprecated + * Creates a new interaction object with default values. + * + * @deprecated Use initInteraction() instead. This function will be removed in a future version. + * + * @returns A new interaction object initialized with default values */ export const interaction = () => { log.deprecate({ @@ -123,24 +133,147 @@ export const interaction = () => { return initInteraction() } +/** + * Checks if a value is a number. + * + * @param d The value to check + * @returns True if the value is a number, false otherwise + * + * @example + * import { isNumber } from "@onflow/sdk" + * + * console.log(isNumber(42)); // true + * console.log(isNumber("42")); // false + * console.log(isNumber(3.14)); // true + * console.log(isNumber(null)); // false + */ export const isNumber = (d: any): d is number => typeof d === "number" + +/** + * Checks if a value is an array. + * + * @param d The value to check + * @returns True if the value is an array, false otherwise + * + * @example + * import { isArray } from "@onflow/sdk" + * + * console.log(isArray([1, 2, 3])); // true + * console.log(isArray("hello")); // false + * console.log(isArray({})); // false + * console.log(isArray(null)); // false + */ export const isArray = (d: any): d is any[] => Array.isArray(d) + +/** + * Checks if a value is an object (but not null). + * + * @param d The value to check + * @returns True if the value is an object and not null, false otherwise + * + * @example + * import { isObj } from "@onflow/sdk" + * + * console.log(isObj({})); // true + * console.log(isObj({name: "Alice"})); // true + * console.log(isObj(null)); // false + * console.log(isObj("string")); // false + * console.log(isObj([])); // true (arrays are objects) + */ export const isObj = (d: any): d is Record => d !== null && typeof d === "object" + +/** + * Checks if a value is null or undefined. + * + * @param d The value to check + * @returns True if the value is null or undefined, false otherwise + * + * @example + * import { isNull } from "@onflow/sdk" + * + * console.log(isNull(null)); // true + * console.log(isNull(undefined)); // true + * console.log(isNull("")); // false + * console.log(isNull(0)); // false + * console.log(isNull(false)); // false + */ export const isNull = (d: any): d is null => d == null + +/** + * Checks if a value is a function. + * + * @param d The value to check + * @returns True if the value is a function, false otherwise + * + * @example + * import { isFn } from "@onflow/sdk" + * + * console.log(isFn(() => {})); // true + * console.log(isFn(function() {})); // true + * console.log(isFn("function")); // false + * console.log(isFn({})); // false + */ export const isFn = (d: any): d is Function => typeof d === "function" +/** + * Checks if an object is a valid interaction. + * + * @param ix The object to check + * @returns True if the object is a valid interaction, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { isInteraction, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * console.log(isInteraction(interaction)); // true + * console.log(isInteraction({})); // false + * console.log(isInteraction(null)); // false + * + * // Check if a builder result is a valid interaction + * const built = await fcl.build([fcl.script`access(all) fun main(): Int { return 42 }`]); + * console.log(isInteraction(built)); // true + */ export const isInteraction = (ix: unknown) => { if (!isObj(ix) || isNull(ix) || isNumber(ix)) return false for (let key of KEYS) if (!ix.hasOwnProperty(key)) return false return true } +/** + * Marks an interaction as successful and returns the interaction object. + * + * @param ix The interaction to mark as successful + * @returns The interaction object with status set to OK + * + * @example + * import { Ok, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * const successfulInteraction = Ok(interaction); + * console.log(successfulInteraction.status); // "OK" + */ export const Ok = (ix: Interaction) => { ix.status = InteractionStatus.OK return ix } +/** + * Marks an interaction as failed with a specific reason and returns the interaction object. + * + * @param ix The interaction to mark as failed + * @param reason The reason for the failure + * @returns The interaction object with status set to BAD and reason set + * + * @example + * import { Bad, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * const failedInteraction = Bad(interaction, "Invalid transaction signature"); + * console.log(failedInteraction.status); // "BAD" + * console.log(failedInteraction.reason); // "Invalid transaction signature" + */ export const Bad = (ix: Interaction, reason: string) => { ix.status = InteractionStatus.BAD ix.reason = reason @@ -170,8 +303,31 @@ interface IPrepAccountOpts { role?: TransactionRole | null } +/** + * Creates a new account object with default values. + * + * @returns A new account object initialized with default values + * + * @example + * import { initAccount } from "@onflow/sdk" + * + * const account = initAccount(); + * console.log(account.addr); // null + * console.log(account.keyId); // null + * console.log(account.role.proposer); // false + * + * // Typically used internally by other functions + * // You'll more commonly use authorization() or prepAccount() + */ export const initAccount = (): InteractionAccount => JSON.parse(ACCT) +/** + * Prepares and configures an account for use in an interaction with a specific role. + * + * @param acct The account authorization function or account object + * @param opts Configuration options including the role for the account + * @returns A function that adds the prepared account to an interaction + */ export const prepAccount = (acct: AccountAuthorization, opts: IPrepAccountOpts = {}) => (ix: Interaction) => { @@ -222,6 +378,36 @@ export const prepAccount = return ix } +/** + * Creates an argument resolver and adds it to an interaction. + * + * This function is typically used internally by the SDK to handle arguments in scripts and transactions. + * For most use cases, you should use `fcl.arg()` instead of this function directly. + * + * @param arg The argument configuration object + * @returns A function that adds the argument to an interaction + * + * @example + * import { makeArgument, initInteraction } from "@onflow/sdk" + * import * as fcl from "@onflow/fcl"; + * + * const interaction = initInteraction(); + * + * // Create an argument resolver (usually you'd use fcl.arg instead) + * const argResolver = { + * value: 42, + * xform: fcl.t.Int, + * resolve: (value, xform) => ({ value, xform }) + * }; + * + * // Add the argument to the interaction + * makeArgument(argResolver)(interaction); + * + * console.log(interaction.message.arguments.length); // 1 + * + * // Preferred way - use fcl.arg instead: + * // fcl.args([fcl.arg(42, fcl.t.Int)]) + */ export const makeArgument = (arg: Record) => (ix: Interaction) => { let tempId = uuidv4() ix.message.arguments.push(tempId) @@ -305,14 +491,112 @@ export const isSubscribeEvents /* */ = is( InteractionTag.SUBSCRIBE_EVENTS ) +/** + * Checks if an interaction has a successful status. + * + * @param ix The interaction to check + * @returns True if the interaction status is OK, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { isOk } from "@onflow/sdk" + * + * // Check if a transaction was successful + * const response = await fcl.send([ + * fcl.transaction`transaction { prepare(account: AuthAccount) {} }` + * ]); + * + * if (isOk(response)) { + * console.log("Transaction was successful"); + * } else { + * console.log("Transaction failed"); + * } + */ export const isOk /* */ = (ix: Interaction) => ix.status === InteractionStatus.OK + +/** + * Checks if an interaction has a failed status. + * + * @param ix The interaction to check + * @returns True if the interaction status is BAD, false otherwise + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { isBad, why } from "@onflow/sdk" + * + * const response = await fcl.send([ + * fcl.transaction`transaction { prepare(account: AuthAccount) {} }` + * ]); + * + * if (isBad(response)) { + * console.log("Transaction failed:", why(response)); + * } + */ export const isBad /* */ = (ix: Interaction) => ix.status === InteractionStatus.BAD + +/** + * Returns the reason for an interaction failure. + * + * @param ix The interaction to get the failure reason from + * @returns The reason string or undefined if no reason is set + * + * @example + * import { Bad, why, initInteraction } from "@onflow/sdk" + * + * const interaction = Bad(initInteraction(), "Network timeout"); + * console.log(why(interaction)); // "Network timeout" + * + * // Used with error handling + * if (isBad(response)) { + * console.error("Error occurred:", why(response)); + * } + */ export const why /* */ = (ix: Interaction) => ix.reason +/** + * Checks if an object is an account resolver. + * + * @param account The object to check + * @returns True if the object is an account resolver, false otherwise + * + * @example + * import { isAccount, authorization } from "@onflow/sdk" + * + * const authz = authorization("0x123", signingFunction); + * const accountResolver = { kind: "ACCOUNT", addr: "0x123" }; + * const regularObject = { name: "test" }; + * + * console.log(isAccount(accountResolver)); // true + * console.log(isAccount(regularObject)); // false + */ export const isAccount /* */ = (account: Record) => account.kind === InteractionResolverKind.ACCOUNT + +/** + * Checks if an object is an argument resolver. + * + * @param argument The object to check + * @returns True if the object is an argument resolver, false otherwise + * + * @example + * import { isArgument, arg } from "@onflow/sdk" + * + * const argumentResolver = { kind: "ARGUMENT", value: 42 }; + * const regularObject = { value: 42 }; + * + * console.log(isArgument(argumentResolver)); // true + * console.log(isArgument(regularObject)); // false + * + * // Check arguments in a script + * const scriptArgs = [arg(10, t.Int), arg("hello", t.String)]; + * scriptArgs.forEach(arg => { + * if (isArgument(arg)) { + * console.log("Valid argument:", arg.value); + * } + * }); + */ export const isArgument /* */ = (argument: Record) => argument.kind === InteractionResolverKind.ARGUMENT @@ -346,8 +630,45 @@ const recPipe = async ( } /** - * @description Async pipe function to compose interactions - * @returns An interaction object + * Async pipe function to compose interactions. + * + * The pipe function is the foundation for composing multiple interaction builder functions together. + * It sequentially applies builder functions to an interaction, allowing for complex interaction construction. + * Each function in the pipe receives the result of the previous function and can modify or validate the interaction. + * + * Pipe has two main forms: + * 1. `pipe(builderFunctions)`: Returns a builder function + * 2. `pipe(interaction, builderFunctions)`: Directly executes the pipe on an interaction + * + * @param fns Array of builder functions to apply + * @returns An interaction builder function when called with just functions, or a Promise when called with an interaction and functions + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Using pipe to create a reusable builder + * const myTransactionBuilder = fcl.pipe([ + * fcl.transaction` + * transaction(amount: UFix64) { + * prepare(account: AuthAccount) { + * log(amount) + * } + * } + * `, + * fcl.args([fcl.arg("10.0", fcl.t.UFix64)]), + * fcl.proposer(fcl.authz), + * fcl.payer(fcl.authz), + * fcl.authorizations([fcl.authz]), + * fcl.limit(100) + * ]); + * + * // Use the builder + * const interaction = await fcl.build([myTransactionBuilder]); + * + * // Pipe is used internally by build() and send() + * await fcl.send([ + * fcl.script`access(all) fun main(): Int { return 42 }` + * ]); // This uses pipe internally */ function pipe(fns: (InteractionBuilderFn | false)[]): InteractionBuilderFn function pipe( @@ -370,6 +691,30 @@ export {pipe} const identity = (v: T, ..._: any[]) => v +/** + * Gets a value from an interaction object using a dot-notation key path. + * + * @param ix The interaction object + * @param key The dot-notation key path (e.g., "message.arguments") + * @param fallback The fallback value if the key is not found + * @returns The value at the key path or the fallback value + * + * @example + * import { get, put, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * + * // Set a value first + * put("user.name", "Alice")(interaction); + * + * // Get the value + * const userName = get(interaction, "user.name"); // "Alice" + * const userAge = get(interaction, "user.age", 25); // 25 (fallback) + * + * // Get nested values + * put("config.network.url", "https://access.mainnet.onflow.org")(interaction); + * const networkUrl = get(interaction, "config.network.url"); + */ export const get = ( ix: Interaction, key: string, @@ -378,11 +723,59 @@ export const get = ( return ix.assigns[key] == null ? fallback : ix.assigns[key] } +/** + * Sets a value in an interaction object using a dot-notation key path. + * + * @param key The dot-notation key path (e.g., "message.arguments") + * @param value The value to set + * @returns A function that takes an interaction and sets the value + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { put } from "@onflow/sdk" + * + * // Using put in a custom builder function + * const setCustomData = (data) => put("custom.data", data); + * + * await fcl.send([ + * fcl.script`access(all) fun main(): String { return "Hello" }`, + * setCustomData({ userId: 123, timestamp: Date.now() }) + * ]); + * + * // Direct usage + * const interaction = initInteraction(); + * put("network.endpoint", "https://access.mainnet.onflow.org")(interaction); + */ export const put = (key: string, value: any) => (ix: Interaction) => { ix.assigns[key] = value return Ok(ix) } +/** + * Updates a value in an interaction object using a transformation function. + * + * @param key The dot-notation key path to update + * @param fn The transformation function to apply to the existing value + * @returns A function that takes an interaction and updates the value + * + * @example + * import { update, put, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * + * // Set initial value + * put("counter", 0)(interaction); + * + * // Increment counter + * const increment = update("counter", (current) => (current || 0) + 1); + * increment(interaction); // counter becomes 1 + * increment(interaction); // counter becomes 2 + * + * // Update array + * put("tags", ["flow", "blockchain"])(interaction); + * const addTag = update("tags", (tags) => [...(tags || []), "web3"]); + * addTag(interaction); // tags becomes ["flow", "blockchain", "web3"] + */ export const update = (key: string, fn: (v: T | T[], ...args: any[]) => T | T[] = identity) => (ix: Interaction) => { @@ -390,6 +783,30 @@ export const update = return Ok(ix) } +/** + * Removes a property from an interaction object using a dot-notation key path. + * + * @param key The dot-notation key path to remove + * @returns A function that takes an interaction and removes the property + * + * @example + * import { destroy, put, get, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * + * // Set some values + * put("user.name", "Alice")(interaction); + * put("user.email", "alice@example.com")(interaction); + * put("user.temp", "temporary data")(interaction); + * + * console.log(get(interaction, "user.temp")); // "temporary data" + * + * // Remove temporary data + * destroy("user.temp")(interaction); + * + * console.log(get(interaction, "user.temp")); // undefined + * console.log(get(interaction, "user.name")); // "Alice" (still exists) + */ export const destroy = (key: string) => (ix: Interaction) => { delete ix.assigns[key] return Ok(ix) diff --git a/packages/sdk/src/node-version-info/node-version-info.ts b/packages/sdk/src/node-version-info/node-version-info.ts index a2e2786a0..7ee3eab34 100644 --- a/packages/sdk/src/node-version-info/node-version-info.ts +++ b/packages/sdk/src/node-version-info/node-version-info.ts @@ -4,8 +4,33 @@ import {decodeResponse as decode} from "../decode/decode" import {send} from "../transport" /** - * @description Returns the version information from to connected node + * Retrieve version information from the connected Flow Access Node. + * + * This function returns detailed information about the Flow node's version, including the protocol version, spork information, and node-specific details. This is useful for debugging, compatibility checks, and understanding the network state. + * + * @param opts Optional parameters for the request * @returns A promise that resolves to a block response + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Get node version information + * const versionInfo = await fcl.nodeVersionInfo(); + * console.log(versionInfo); + * // { + * // semver: "v0.37.13", + * // commit: "12345abcd", + * // spork_id: "mainnet-23", + * // protocol_version: "2.13.10", + * // spork_root_block_height: "88483760", + * // node_root_block_height: "88483760" + * // } + * + * // Check compatibility + * const info = await fcl.nodeVersionInfo(); + * if (info.protocol_version.startsWith("2.13")) { + * console.log("Compatible with current protocol version"); + * } */ export async function nodeVersionInfo( opts: any = {} diff --git a/packages/sdk/src/resolve/resolve-accounts.ts b/packages/sdk/src/resolve/resolve-accounts.ts index 1c6e832c2..2b1c4edc5 100644 --- a/packages/sdk/src/resolve/resolve-accounts.ts +++ b/packages/sdk/src/resolve/resolve-accounts.ts @@ -18,11 +18,13 @@ const isFn = (v: any): v is Function => const genAccountId = (...ids: (string | boolean | undefined)[]) => ids.join("-") -enum ROLES { - PAYER = "payer", - PROPOSER = "proposer", - AUTHORIZATIONS = "authorizations", -} +const ROLES = { + PAYER: "payer", + PROPOSER: "proposer", + AUTHORIZATIONS: "authorizations", +} as const + +type ROLES = (typeof ROLES)[keyof typeof ROLES] function debug() { const SPACE = " " @@ -51,6 +53,13 @@ function recurseFlatMap(el: T, depthLimit = 3) { ) } +/** + * Builds a pre-signable object containing interaction data before signing. + * + * @param acct The account to create the pre-signable for + * @param ix The interaction object containing transaction details + * @returns A pre-signable object conforming to the FCL pre-signable standard + */ export function buildPreSignable( acct: Partial, ix: Interaction @@ -317,6 +326,13 @@ async function resolveAccountsByIds( return newTempIds } +/** + * Resolves account authorization functions and validates account configurations for transactions. + * + * @param ix The interaction object containing accounts to resolve + * @param opts Configuration options for resolution + * @returns The interaction with resolved accounts + */ export async function resolveAccounts( ix: Interaction, opts: Record = {} diff --git a/packages/sdk/src/resolve/resolve-arguments.ts b/packages/sdk/src/resolve/resolve-arguments.ts index 7b3b1aaff..64af9a861 100644 --- a/packages/sdk/src/resolve/resolve-arguments.ts +++ b/packages/sdk/src/resolve/resolve-arguments.ts @@ -40,6 +40,38 @@ async function handleArgResolution( } } +/** + * Resolves transaction arguments by evaluating argument functions and converting them to appropriate types. + * + * This function processes all arguments in a transaction or script interaction, calling their transform functions + * to convert JavaScript values into Cadence-compatible argument formats that can be sent to the Flow network. + * + * The resolution process includes: + * - Calling argument resolver functions if present + * - Applying type transformations using the xform field + * - Handling recursive argument resolution up to a depth limit + * + * @param ix The interaction object containing arguments to resolve + * @returns The interaction with resolved arguments ready for network transmission + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // Arguments are automatically resolved during send() + * await fcl.send([ + * fcl.script` + * access(all) fun main(amount: UFix64, recipient: Address): String { + * return "Sending ".concat(amount.toString()).concat(" to ").concat(recipient.toString()) + * } + * `, + * fcl.args([ + * fcl.arg("100.0", fcl.t.UFix64), // Will be resolved to Cadence UFix64 + * fcl.arg("0x01", fcl.t.Address) // Will be resolved to Cadence Address + * ]) + * ]).then(fcl.decode); + * + * // The resolveArguments function handles the conversion automatically + */ export async function resolveArguments(ix: Interaction): Promise { if (isTransaction(ix) || isScript(ix)) { for (let [id, arg] of Object.entries(ix.arguments)) { diff --git a/packages/sdk/src/resolve/resolve-cadence.ts b/packages/sdk/src/resolve/resolve-cadence.ts index af9809385..f8feb699a 100644 --- a/packages/sdk/src/resolve/resolve-cadence.ts +++ b/packages/sdk/src/resolve/resolve-cadence.ts @@ -24,6 +24,12 @@ function getContractIdentifierSyntaxMatches( return cadence.matchAll(newIdentifierPatternFn()) } +/** + * Resolves Cadence code by evaluating functions and replacing contract placeholders with addresses. + * + * @param ix The interaction object containing Cadence code to resolve + * @returns The interaction with resolved Cadence code + */ export async function resolveCadence(ix: Interaction): Promise { if (!isTransaction(ix) && !isScript(ix)) return ix diff --git a/packages/sdk/src/resolve/resolve-compute-limit.ts b/packages/sdk/src/resolve/resolve-compute-limit.ts index 7e693c7e4..0965eb765 100644 --- a/packages/sdk/src/resolve/resolve-compute-limit.ts +++ b/packages/sdk/src/resolve/resolve-compute-limit.ts @@ -5,6 +5,12 @@ import {Interaction} from "@onflow/typedefs" const DEFAULT_COMPUTE_LIMIT = 100 +/** + * Resolves the compute limit for a transaction from configuration or applies default values. + * + * @param ix The interaction object to resolve compute limit for + * @returns The interaction with resolved compute limit + */ export async function resolveComputeLimit( ix: Interaction ): Promise { diff --git a/packages/sdk/src/resolve/resolve-final-normalization.ts b/packages/sdk/src/resolve/resolve-final-normalization.ts index f7593d139..d5e5ad2f3 100644 --- a/packages/sdk/src/resolve/resolve-final-normalization.ts +++ b/packages/sdk/src/resolve/resolve-final-normalization.ts @@ -1,6 +1,12 @@ import {sansPrefix} from "@onflow/util-address" import {Interaction} from "@onflow/typedefs" +/** + * Normalizes account addresses by removing the "0x" prefix from all account addresses in the interaction. + * + * @param ix The interaction object to normalize + * @returns The interaction with normalized account addresses + */ export async function resolveFinalNormalization( ix: Interaction ): Promise { diff --git a/packages/sdk/src/resolve/resolve-proposer-sequence-number.ts b/packages/sdk/src/resolve/resolve-proposer-sequence-number.ts index 5dcea631c..a76504217 100644 --- a/packages/sdk/src/resolve/resolve-proposer-sequence-number.ts +++ b/packages/sdk/src/resolve/resolve-proposer-sequence-number.ts @@ -14,6 +14,12 @@ interface NodeConfig { node: string } +/** + * Resolves the sequence number for the proposer account by querying the blockchain. + * + * @param config Configuration containing the node endpoint + * @returns A function that resolves the proposer sequence number for an interaction + */ export const resolveProposerSequenceNumber = ({node}: NodeConfig) => async (ix: Interaction) => { diff --git a/packages/sdk/src/resolve/resolve-ref-block-id.ts b/packages/sdk/src/resolve/resolve-ref-block-id.ts index c94139619..04ae9ae41 100644 --- a/packages/sdk/src/resolve/resolve-ref-block-id.ts +++ b/packages/sdk/src/resolve/resolve-ref-block-id.ts @@ -32,6 +32,12 @@ async function getRefId(opts?: {[key: string]: any}): Promise { return ix.id } +/** + * Resolves the reference block ID for a transaction by querying the latest block from the network. + * + * @param opts Optional configuration parameters + * @returns A function that resolves the reference block ID for an interaction + */ export function resolveRefBlockId(opts?: {[key: string]: any}) { return async (ix: any) => { if (!isTransaction(ix)) return Ok(ix) diff --git a/packages/sdk/src/resolve/resolve-signatures.ts b/packages/sdk/src/resolve/resolve-signatures.ts index 43f674f47..0743a2070 100644 --- a/packages/sdk/src/resolve/resolve-signatures.ts +++ b/packages/sdk/src/resolve/resolve-signatures.ts @@ -13,6 +13,12 @@ import { findOutsideSigners, } from "./voucher" +/** + * Resolves signatures for a transaction by coordinating the signing process for inside and outside signers. + * + * @param ix The interaction object containing transaction details + * @returns The interaction object with resolved signatures + */ export async function resolveSignatures(ix: Interaction) { if (isTransaction(ix)) { try { @@ -63,6 +69,14 @@ function fetchSignature(ix: Interaction, payload: string) { } } +/** + * Builds a signable object that can be signed by an authorization function. + * + * @param acct The account to create the signable for + * @param message The encoded message to be signed + * @param ix The interaction object containing transaction details + * @returns A signable object conforming to the FCL signable standard + */ export function buildSignable( acct: InteractionAccount, message: string, diff --git a/packages/sdk/src/resolve/resolve-validators.ts b/packages/sdk/src/resolve/resolve-validators.ts index 6ed6ba4c0..13849bfcc 100644 --- a/packages/sdk/src/resolve/resolve-validators.ts +++ b/packages/sdk/src/resolve/resolve-validators.ts @@ -1,6 +1,12 @@ import {get, pipe, Ok, Bad} from "../interaction/interaction" import {Interaction} from "@onflow/typedefs" +/** + * Executes validator functions that have been attached to an interaction to perform validation checks. + * + * @param ix The interaction object containing validators to execute + * @returns The interaction after running all validators + */ export async function resolveValidators(ix: Interaction): Promise { const validators = get(ix, "ix.validators", []) return pipe( diff --git a/packages/sdk/src/resolve/resolve-voucher-intercept.ts b/packages/sdk/src/resolve/resolve-voucher-intercept.ts index ea779de00..03fc82812 100644 --- a/packages/sdk/src/resolve/resolve-voucher-intercept.ts +++ b/packages/sdk/src/resolve/resolve-voucher-intercept.ts @@ -2,6 +2,12 @@ import {get, isFn} from "../interaction/interaction" import {Interaction} from "@onflow/typedefs" import {createSignableVoucher} from "./voucher" +/** + * Resolves voucher intercept functions by calling them with the current voucher. + * + * @param ix The interaction object to resolve voucher intercepts for + * @returns The interaction after voucher intercept processing + */ export async function resolveVoucherIntercept( ix: Interaction ): Promise { diff --git a/packages/sdk/src/resolve/resolve.ts b/packages/sdk/src/resolve/resolve.ts index 8466b7b32..b91ffcb66 100644 --- a/packages/sdk/src/resolve/resolve.ts +++ b/packages/sdk/src/resolve/resolve.ts @@ -51,6 +51,31 @@ const debug = return ix } +/** + * Resolves an interaction by applying a series of resolvers in sequence. + * + * This is the main resolver function that takes a built interaction and prepares it + * for submission to the Flow blockchain by applying all necessary resolvers. + * + * The resolve function uses a pipeline approach, applying each resolver in sequence + * to transform the interaction from its initial built state to a fully resolved state + * ready for transmission to the Flow Access API. + * + * @param interaction The interaction object to resolve + * @returns A promise that resolves to the fully resolved interaction + * @example + * import { resolve, build, script } from "@onflow/sdk" + * + * const interaction = await build([ + * script` + * access(all) fun main(): String { + * return "Hello, World!" + * } + * ` + * ]) + * + * const resolved = await resolve(interaction) + */ export const resolve = pipe([ resolveCadence, debug("cadence", (ix: Interaction, log: any) => log(ix.message.cadence)), diff --git a/packages/sdk/src/resolve/voucher.ts b/packages/sdk/src/resolve/voucher.ts index 4e0cf97e4..7682e8379 100644 --- a/packages/sdk/src/resolve/voucher.ts +++ b/packages/sdk/src/resolve/voucher.ts @@ -2,6 +2,28 @@ import {withPrefix} from "@onflow/util-address" import {Voucher, encodeTxIdFromVoucher} from "../encode/encode" import {Interaction} from "@onflow/typedefs" +/** + * Identifies signers for the transaction payload (authorizers + proposer, excluding payer). + * + * This function determines which accounts need to sign the transaction payload. Payload signers include + * all authorizers and the proposer, but exclude the payer (who signs the envelope). + * + * @param ix The interaction object + * @returns Array of account tempIds that need to sign the payload + * + * @example + * import { findInsideSigners, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * // Assume we have account tempIds: "proposer-123", "auth-456", "payer-789" + * interaction.proposer = "proposer-123"; + * interaction.authorizations = ["auth-456"]; + * interaction.payer = "payer-789"; + * + * const insideSigners = findInsideSigners(interaction); + * console.log(insideSigners); // ["auth-456", "proposer-123"] + * // Note: payer is excluded from payload signers + */ export function findInsideSigners(ix: Interaction) { // Inside Signers Are: (authorizers + proposer) - payer let inside = new Set(ix.authorizations) @@ -16,12 +38,77 @@ export function findInsideSigners(ix: Interaction) { return Array.from(inside) } +/** + * Identifies signers for the transaction envelope (payer accounts only). + * + * This function determines which accounts need to sign the transaction envelope. Envelope signers + * are only the payer accounts, who are responsible for transaction fees. + * + * @param ix The interaction object + * @returns Array of account tempIds that need to sign the envelope + * + * @example + * import { findOutsideSigners, initInteraction } from "@onflow/sdk" + * + * const interaction = initInteraction(); + * interaction.proposer = "proposer-123"; + * interaction.authorizations = ["auth-456"]; + * interaction.payer = "payer-789"; + * + * const outsideSigners = findOutsideSigners(interaction); + * console.log(outsideSigners); // ["payer-789"] + * // Only the payer signs the envelope + * + * // Multiple payers example + * interaction.payer = ["payer-789", "payer-abc"]; + * const multiplePayerSigners = findOutsideSigners(interaction); + * console.log(multiplePayerSigners); // ["payer-789", "payer-abc"] + */ export function findOutsideSigners(ix: Interaction) { // Outside Signers Are: (payer) let outside = new Set(Array.isArray(ix.payer) ? ix.payer : [ix.payer]) return Array.from(outside) } +/** + * Creates a signable voucher object from an interaction for signing purposes. + * + * A voucher is a standardized representation of a transaction that contains all the necessary + * information for signing and submitting to the Flow network. This function transforms an + * interaction object into a voucher format. + * + * @param ix The interaction object containing transaction details + * @returns A voucher object containing all transaction data and signatures + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { createSignableVoucher } from "@onflow/sdk" + * + * // Build a transaction interaction + * const interaction = await fcl.build([ + * fcl.transaction` + * transaction(amount: UFix64) { + * prepare(account: AuthAccount) { + * log(amount) + * } + * } + * `, + * fcl.args([fcl.arg("10.0", fcl.t.UFix64)]), + * fcl.proposer(proposerAuthz), + * fcl.payer(payerAuthz), + * fcl.authorizations([authorizerAuthz]), + * fcl.limit(100) + * ]); + * + * // Create a voucher for signing + * const voucher = createSignableVoucher(interaction); + * console.log(voucher.cadence); // The Cadence script + * console.log(voucher.arguments); // The transaction arguments + * console.log(voucher.proposalKey); // Proposer account details + * console.log(voucher.authorizers); // List of authorizer addresses + * + * // The voucher can now be signed and submitted + */ export const createSignableVoucher = (ix: Interaction) => { const buildAuthorizers = () => { const authorizations = ix.authorizations @@ -69,6 +156,44 @@ export const createSignableVoucher = (ix: Interaction) => { } } +/** + * Converts a voucher object to a transaction ID. + * + * This function computes the transaction ID by encoding and hashing the voucher. + * The transaction ID can be used to track the transaction status on the Flow network. + * + * @param voucher The voucher object to convert + * @returns A transaction ID string + * + * @example + * import { voucherToTxId, createSignableVoucher } from "@onflow/sdk" + * import * as fcl from "@onflow/fcl"; + * + * // Create a voucher from an interaction + * const interaction = await fcl.build([ + * fcl.transaction` + * transaction { + * prepare(account: AuthAccount) { + * log("Hello, Flow!") + * } + * } + * `, + * fcl.proposer(authz), + * fcl.payer(authz), + * fcl.authorizations([authz]) + * ]); + * + * const voucher = createSignableVoucher(interaction); + * + * // Calculate the transaction ID + * const txId = voucherToTxId(voucher); + * console.log("Transaction ID:", txId); + * // Returns something like: "a1b2c3d4e5f6789..." + * + * // You can use this ID to track the transaction + * const txStatus = await fcl.tx(txId).onceSealed(); + * console.log("Transaction status:", txStatus); + */ export const voucherToTxId = (voucher: Voucher) => { return encodeTxIdFromVoucher(voucher) } diff --git a/packages/sdk/src/response/response.ts b/packages/sdk/src/response/response.ts index f79e1e744..6a6fbcae1 100644 --- a/packages/sdk/src/response/response.ts +++ b/packages/sdk/src/response/response.ts @@ -19,4 +19,21 @@ const DEFAULT_RESPONSE = { nodeVersionInfo: null, } +/** + * Creates a default response object + * + * @returns A default response object + * + * @example + * import { response } from "@onflow/sdk" + * + * // Create a default response object + * const defaultResponse = response(); + * console.log(defaultResponse.transaction); // null + * console.log(defaultResponse.account); // null + * console.log(defaultResponse.block); // null + * + * // Typically used internally by the SDK to initialize responses + * // You'll rarely need to use this directly in application code + */ export const response = () => ({...DEFAULT_RESPONSE}) diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index f86ee7e8c..d56991f27 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -43,16 +43,24 @@ export { } from "./interaction/interaction" import type {CadenceArgument} from "./interaction/interaction" export {CadenceArgument} // Workaround for babel https://github.com/babel/babel/issues/8361 -import type {InteractionBuilderFn} from "./interaction/interaction" -export {InteractionBuilderFn} +import type { + InteractionBuilderFn, + AccountAuthorization, + AuthorizationFn, +} from "./interaction/interaction" +export {InteractionBuilderFn, AccountAuthorization, AuthorizationFn} export {createSignableVoucher, voucherToTxId} from "./resolve/voucher" export {encodeMessageFromSignable} from "./wallet-utils/encode-signable" export {template as cadence} from "@onflow/util-template" export {template as cdc} from "@onflow/util-template" -import type {Voucher} from "./wallet-utils/encode-signable" -export {Voucher} +import type { + Voucher, + Signable, + PayloadSig, +} from "./wallet-utils/encode-signable" +export {Voucher, Signable, PayloadSig} // Helpers export {account} from "./account/account" @@ -102,7 +110,13 @@ export {resolveVoucherIntercept} from "./resolve/resolve-voucher-intercept" export {config} from "@onflow/config" -// Deprecated +/** + * Legacy function for setting parameters on an interaction. + * + * @deprecated This function has been removed. Use `args` instead. + * + * @param params The parameters to set + */ export const params = (params: never) => logger.log.deprecate({ pkg: "FCL/SDK", @@ -111,6 +125,14 @@ export const params = (params: never) => "https://github.com/onflow/flow-js-sdk/blob/master/packages/sdk/TRANSITIONS.md#0001-deprecate-params", level: logger.LEVELS.error, }) + +/** + * Legacy function for setting a single parameter on an interaction. + * + * @deprecated This function has been removed. Use `arg` instead. + * + * @param params The parameter to set + */ export const param = (params: never) => logger.log.deprecate({ pkg: "FCL/SDK", diff --git a/packages/sdk/src/test-utils/authz-fn.ts b/packages/sdk/src/test-utils/authz-fn.ts index 48d935ed8..1aaf888ee 100644 --- a/packages/sdk/src/test-utils/authz-fn.ts +++ b/packages/sdk/src/test-utils/authz-fn.ts @@ -1,9 +1,21 @@ import {InteractionAccount} from "@onflow/typedefs" import {withPrefix} from "@onflow/util-address" +/** + * Generates a unique identifier for an account based on its address and key ID. + * + * @param acct The account object + * @returns A string identifier in the format "address-keyId" + */ export const idof = (acct: InteractionAccount) => `${withPrefix(acct.addr)}-${acct.keyId}` +/** + * Generates a test signature string for an account. + * + * @param opts Partial account object containing address and keyId + * @returns A test signature string in the format "SIGNATURE.address.keyId" + */ export function sig(opts: Partial) { return ["SIGNATURE", opts.addr, opts.keyId].join(".") } @@ -12,6 +24,12 @@ interface IAuthzOpts { signingFunction?: (signable: any) => any } +/** + * Creates a test authorization function for testing transactions. + * + * @param opts Optional configuration including custom signing function + * @returns An authorization function that can be used in tests + */ export function authzFn(opts: IAuthzOpts = {}) { return function (account: Partial) { const acct: Partial = { @@ -40,6 +58,12 @@ interface IAuthzResolveOpts { tempId?: string } +/** + * Creates a test authorization resolver that can be used for testing account resolution. + * + * @param opts Optional configuration including temporary ID + * @returns A function that returns an account with resolve capability + */ export function authzResolve(opts: IAuthzResolveOpts = {}) { return function (account: InteractionAccount) { const {tempId, ...rest} = opts @@ -61,6 +85,12 @@ interface IAuthzResolveMany { payer?: any } +/** + * Creates a test authorization resolver that handles multiple accounts with different roles. + * + * @param opts Configuration including authorizations array and optional proposer/payer + * @returns A function that returns an account with multi-role resolve capability + */ export function authzResolveMany( opts: IAuthzResolveMany = {authorizations: []} ) { @@ -82,6 +112,13 @@ export function authzResolveMany( } } +/** + * Creates a deep test authorization resolver with nested resolution for complex testing scenarios. + * + * @param opts Configuration including authorizations array and optional proposer/payer + * @param depth The depth of nesting for the resolver (default: 1) + * @returns A function that returns an account with deep nested resolve capability + */ export function authzDeepResolveMany( opts: IAuthzResolveMany = {authorizations: []}, depth = 1 diff --git a/packages/sdk/src/test-utils/run.ts b/packages/sdk/src/test-utils/run.ts index fbc70bf48..50fcd56eb 100644 --- a/packages/sdk/src/test-utils/run.ts +++ b/packages/sdk/src/test-utils/run.ts @@ -3,6 +3,45 @@ import {resolve} from "../resolve/resolve" import {ref} from "../build/build-ref" import {Interaction} from "@onflow/typedefs" +/** + * Runs a set of functions on an interaction + * + * This is a utility function for testing that builds and resolves an interaction with the provided builder functions. + * It automatically adds a reference block and then resolves the interaction for testing purposes. + * + * @param fns An array of functions to run on the interaction + * @returns A promise that resolves to the resolved interaction + * + * @example + * import { run } from "@onflow/sdk" + * import * as fcl from "@onflow/fcl"; + * + * // Test a simple script interaction + * const result = await run([ + * fcl.script` + * access(all) fun main(): Int { + * return 42 + * } + * ` + * ]); + * + * console.log(result.cadence); // The Cadence script + * console.log(result.tag); // "SCRIPT" + * + * // Test a transaction with arguments + * const txResult = await run([ + * fcl.transaction` + * transaction(amount: UFix64) { + * prepare(account: AuthAccount) { + * log(amount) + * } + * } + * `, + * fcl.args([fcl.arg("10.0", fcl.t.UFix64)]) + * ]); + * + * console.log(txResult.message.arguments); // The resolved arguments + */ export const run = ( fns: Array<(ix: Interaction) => Interaction | Promise> = [] ) => build([ref("123"), ...fns]).then(resolve) diff --git a/packages/sdk/src/transport/get-transport.ts b/packages/sdk/src/transport/get-transport.ts index f08a2345f..55dc2e16d 100644 --- a/packages/sdk/src/transport/get-transport.ts +++ b/packages/sdk/src/transport/get-transport.ts @@ -6,8 +6,36 @@ import {SubscriptionsNotSupportedError} from "./subscribe/errors" /** * Get the SDK transport object, either from the provided override or from the global config. - * @param overrides - Override default configuration with custom transport or send function. - * @returns The SDK transport object. + * + * The transport object handles communication with Flow Access Nodes, including sending transactions, + * executing scripts, and managing subscriptions. This function resolves the transport configuration + * from various sources with the following priority order: + * 1. Provided override parameters + * 2. Global SDK configuration + * 3. Default HTTP transport + * + * @param override Override default configuration with custom transport or send function + * @param override.send Custom send function for backwards compatibility with legacy configurations + * @param override.transport Complete transport object with both send and subscribe capabilities + * @returns The resolved SDK transport object with send and subscribe methods + * + * @throws {Error} When both transport and send options are provided simultaneously + * @throws {SubscriptionsNotSupportedError} When attempting to subscribe using a legacy send-only transport + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { httpTransport } from "@onflow/transport-http"; + * + * // Get default transport (usually HTTP transport) + * const defaultTransport = await fcl.getTransport(); + * + * // Override with custom transport + * const customTransport = await fcl.getTransport({ + * transport: httpTransport({ + * accessNode: "https://rest-mainnet.onflow.org", + * timeout: 10000 + * }) + * }); */ export async function getTransport( override: { diff --git a/packages/sdk/src/transport/send/send.ts b/packages/sdk/src/transport/send/send.ts index a631bb84e..b754fe47a 100644 --- a/packages/sdk/src/transport/send/send.ts +++ b/packages/sdk/src/transport/send/send.ts @@ -8,10 +8,37 @@ import {resolve as defaultResolve} from "../../resolve/resolve" import {getTransport} from "../get-transport" /** - * @description - Sends arbitrary scripts, transactions, and requests to Flow - * @param args - An array of functions that take interaction and return interaction - * @param opts - Optional parameters - * @returns - A promise that resolves to a response + * Sends arbitrary scripts, transactions, and requests to Flow. + * + * This method consumes an array of builders that are to be resolved and sent. The builders required to be included in the array depend on the interaction that is being built. + * + * WARNING: Must be used in conjunction with 'fcl.decode(response)' to get back correct keys and all values in JSON. + * + * @param args An array of builders (functions that take an interaction object and return a new interaction object) + * @param opts Additional optional options for the request + * @param opts.node Custom node endpoint to use for this request + * @param opts.resolve Custom resolve function to use for processing the interaction + * @returns A promise that resolves to a ResponseObject containing the data returned from the chain. Should always be decoded with fcl.decode() to get back appropriate JSON keys and values. + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // a script only needs to resolve the arguments to the script + * const response = await fcl.send([fcl.script`${script}`, fcl.args(args)]); + * // note: response values are encoded, call await fcl.decode(response) to get JSON + * + * // a transaction requires multiple 'builders' that need to be resolved prior to being sent to the chain - such as setting the authorizations. + * const response = await fcl.send([ + * fcl.transaction` + * ${transaction} + * `, + * fcl.args(args), + * fcl.proposer(proposer), + * fcl.authorizations(authorizations), + * fcl.payer(payer), + * fcl.limit(9999) + * ]); + * // note: response contains several values */ export const send = async ( args: (Function | false) | (Function | false)[] = [], diff --git a/packages/sdk/src/transport/subscribe/subscribe-raw.ts b/packages/sdk/src/transport/subscribe/subscribe-raw.ts index f28069e5f..e4a15d0a5 100644 --- a/packages/sdk/src/transport/subscribe/subscribe-raw.ts +++ b/packages/sdk/src/transport/subscribe/subscribe-raw.ts @@ -6,9 +6,58 @@ import {SubscribeRawParams} from "./types" /** * Subscribe to a topic without decoding the data. - * @param params - The parameters for the subscription. - * @param opts - Additional options for the subscription. - * @returns A promise that resolves once the subscription is active. + * + * This function creates a raw subscription to Flow blockchain data streams without automatic decoding. + * It's useful when you need more control over data processing or want to handle raw responses directly. + * For most use cases, consider using the `subscribe()` function instead which provides automatic decoding. + * + * Available topics include: `events`, `blocks`, `block_headers`, `block_digests`, `transaction_statuses`, `account_statuses`. + * + * @param params The parameters for the subscription including topic, arguments, and callbacks + * @param params.topic The subscription topic (e.g., 'events', 'blocks', 'transaction_statuses') + * @param params.args Parameters specific to the topic (e.g., event types, block height, transaction ID) + * @param params.onData Callback function called with raw data when new messages are received + * @param params.onError Callback function called if an error occurs during the subscription + * @param opts Additional options for the subscription + * @param opts.node Custom node endpoint to be used for the subscription + * @param opts.transport Custom transport implementation for handling the connection + * @returns A subscription object with an unsubscribe method + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { SubscriptionTopic } from "@onflow/sdk"; + * + * // Subscribe to raw event data without automatic decoding + * const rawSubscription = fcl.subscribeRaw({ + * topic: SubscriptionTopic.EVENTS, + * args: { + * eventTypes: ["A.7e60df042a9c0868.FlowToken.TokensWithdrawn"] + * }, + * onData: (rawData) => { + * console.log("Raw event data:", rawData); + * // Handle raw data manually - no automatic decoding + * }, + * onError: (error) => { + * console.error("Raw subscription error:", error); + * } + * }); + * + * // Subscribe to raw block data + * const blockSubscription = fcl.subscribeRaw({ + * topic: SubscriptionTopic.BLOCKS, + * args: { + * blockStatus: "finalized" + * }, + * onData: (rawBlock) => { + * console.log("Raw block data:", rawBlock); + * }, + * onError: (error) => { + * console.error("Error:", error); + * } + * }); + * + * // Unsubscribe when done + * rawSubscription.unsubscribe(); */ export function subscribeRaw( {topic, args, onData, onError}: SubscribeRawParams, diff --git a/packages/sdk/src/transport/subscribe/subscribe.ts b/packages/sdk/src/transport/subscribe/subscribe.ts index 51dfa53c3..31ed22c3b 100644 --- a/packages/sdk/src/transport/subscribe/subscribe.ts +++ b/packages/sdk/src/transport/subscribe/subscribe.ts @@ -4,10 +4,57 @@ import {decodeResponse} from "../../decode/decode" import {SubscribeParams} from "./types" /** - * Subscribe to a topic and decode the data. - * @param params - The parameters for the subscription. - * @param opts - Additional options for the subscription. - * @returns A promise that resolves when the subscription is active. + * Subscribe to real-time data from the Flow blockchain and automatically decode the responses. + * + * This is a utility function used for subscribing to real-time data from the WebSocket Streaming API. Data returned will be automatically decoded via the 'decode' function. + * + * Available topics include: `events`, `blocks`, `block_headers`, `block_digests`, `transaction_statuses`, `account_statuses`. + * + * @param params The parameters for the subscription including topic, arguments, and callbacks + * @param params.topic The subscription topic (e.g., 'events', 'blocks', 'transaction_statuses') + * @param params.args Parameters specific to the topic (e.g., event types, block height, transaction ID) + * @param params.onData Callback function called with decoded data when new messages are received + * @param params.onError Callback function called if an error occurs during the subscription + * @param opts Additional options for the subscription + * @param opts.node Custom node endpoint to be used for the subscription + * @param opts.transport Custom transport implementation for handling the connection + * @returns A subscription object that allows you to manage the subscription (e.g., to unsubscribe later) + * + * @example + * import * as fcl from "@onflow/fcl"; + * import { SubscriptionTopic } from "@onflow/sdk"; + * + * // Subscribe to events + * const subscription = fcl.subscribe({ + * topic: SubscriptionTopic.EVENTS, + * args: { + * eventTypes: ["A.7e60df042a9c0868.FlowToken.TokensWithdrawn"] + * }, + * onData: (events) => { + * console.log("Received events:", events); + * }, + * onError: (error) => { + * console.error("Subscription error:", error); + * } + * }); + * + * // Subscribe to blocks + * const blockSubscription = fcl.subscribe({ + * topic: SubscriptionTopic.BLOCKS, + * args: { + * blockStatus: "finalized" + * }, + * onData: (block) => { + * console.log("New block:", block); + * }, + * onError: (error) => { + * console.error("Block subscription error:", error); + * } + * }); + * + * // Later, to unsubscribe: + * subscription.unsubscribe(); + * blockSubscription.unsubscribe(); */ export function subscribe( {topic, args, onData, onError}: SubscribeParams, diff --git a/packages/sdk/src/wallet-utils/encode-signable.ts b/packages/sdk/src/wallet-utils/encode-signable.ts index 4814573a2..6148a4ddf 100644 --- a/packages/sdk/src/wallet-utils/encode-signable.ts +++ b/packages/sdk/src/wallet-utils/encode-signable.ts @@ -4,7 +4,7 @@ import { encodeTransactionEnvelope, } from "../encode/encode" -interface PayloadSig { +export interface PayloadSig { address: string keyId: number | string sig: string @@ -27,8 +27,14 @@ export interface Voucher { payloadSigs: PayloadSig[] } -interface Signable { +export interface Signable { + message: string + addr?: string + keyId?: number + signature?: string + roles: Record voucher: Voucher + [key: string]: any } const findPayloadSigners = (voucher: Voucher): string[] => { @@ -56,6 +62,51 @@ export class UnableToDetermineMessageEncodingTypeForSignerAddress extends Error } } +/** + * Encodes a message from a signable object for a specific signer address. + * + * This function determines whether the signer should sign the transaction payload or envelope + * based on their role in the transaction (authorizer, proposer, or payer), then encodes the + * appropriate message for signing. + * + * Payload signers include authorizers and proposers (but not payers) + * Envelope signers include only payers + * + * The encoded message is what gets signed by the account's private key to create the transaction signature. + * + * @param signable The signable object containing transaction data and voucher + * @param signerAddress The address of the signer to encode the message for + * @returns An encoded message string suitable for signing with the account's private key + * + * @throws {UnableToDetermineMessageEncodingTypeForSignerAddress} When the signer address is not found in authorizers, proposer, or payer roles + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // This function is typically used internally by authorization functions + * // when implementing custom wallet connectors or signing flows + * + * const signable = { + * voucher: { + * cadence: "transaction { prepare(acct: AuthAccount) {} }", + * authorizers: ["0x01"], + * proposalKey: { address: "0x01", keyId: 0, sequenceNum: 42 }, + * payer: "0x02", + * refBlock: "a1b2c3", + * computeLimit: 100, + * arguments: [], + * payloadSigs: [] + * } + * }; + * + * // For an authorizer (payload signer) + * const authorizerMessage = fcl.encodeMessageFromSignable(signable, "0x01"); + * console.log("Authorizer signs:", authorizerMessage); + * + * // For a payer (envelope signer) + * const payerMessage = fcl.encodeMessageFromSignable(signable, "0x02"); + * console.log("Payer signs:", payerMessage); + */ export const encodeMessageFromSignable = ( signable: Signable, signerAddress: string diff --git a/packages/sdk/src/wallet-utils/validate-tx.ts b/packages/sdk/src/wallet-utils/validate-tx.ts index e8cff04de..87d151c56 100644 --- a/packages/sdk/src/wallet-utils/validate-tx.ts +++ b/packages/sdk/src/wallet-utils/validate-tx.ts @@ -3,15 +3,7 @@ import { encodeTransactionEnvelope as encodeOutsideMessage, } from "../encode/encode" import {invariant} from "@onflow/util-invariant" - -interface Signable { - roles: { - payer: boolean - } - voucher: any - message: string -} - +import {Signable} from "./encode-signable" const isPayer = (signable: Signable): boolean => { return signable.roles.payer } @@ -30,6 +22,51 @@ const isExpectedMessage = (signable: Signable): boolean => { : encodeInsideMessage(getVoucher(signable)) === getMessage(signable) } +/** + * Validates that a signable transaction is properly formed and contains the expected message. + * + * This function verifies that the message in a signable object matches the expected encoded message + * based on the signer's role (payer or non-payer). It ensures the integrity of the signing process + * by confirming that the message to be signed corresponds correctly to the transaction data. + * + * For payers: Validates against the transaction envelope encoding + * For non-payers (proposers/authorizers): Validates against the transaction payload encoding + * + * @param signable The signable object to validate + * @param signable.roles Object indicating the signer's roles (payer, proposer, authorizer) + * @param signable.voucher The voucher containing transaction data + * @param signable.message The encoded message that should be signed + * @returns True if the signable is valid and ready for signing + * + * @throws {Error} When the signable payload doesn't match the expected transaction encoding + * + * @example + * import * as fcl from "@onflow/fcl"; + * + * // This function is typically used internally by wallet connectors + * // and authorization functions to ensure transaction integrity + * + * const signable = { + * roles: { payer: true, proposer: false, authorizer: false }, + * voucher: { + * cadence: "transaction { prepare(acct: AuthAccount) {} }", + * proposalKey: { address: "0x01", keyId: 0, sequenceNum: 42 }, + * payer: "0x02", + * authorizers: ["0x01"], + * // ... other voucher data + * }, + * message: "encoded_transaction_envelope_here" + * }; + * + * try { + * const isValid = fcl.validateSignableTransaction(signable); + * console.log("Signable is valid:", isValid); + * // Proceed with signing + * } catch (error) { + * console.error("Invalid signable:", error.message); + * // Handle validation failure + * } + */ export const validateSignableTransaction = (signable: Signable): boolean => { invariant(isExpectedMessage(signable), "Signable payload must be transaction") diff --git a/packages/transport-http/src/subscribe/handlers/account-statuses.ts b/packages/transport-http/src/subscribe/handlers/account-statuses.ts index dabe3ab0b..c71762f85 100644 --- a/packages/transport-http/src/subscribe/handlers/account-statuses.ts +++ b/packages/transport-http/src/subscribe/handlers/account-statuses.ts @@ -5,10 +5,13 @@ import { } from "@onflow/typedefs" import {createSubscriptionHandler} from "./types" -type AccountStatusesArgs = SubscriptionArgs +type AccountStatusesArgs = SubscriptionArgs< + typeof SubscriptionTopic.ACCOUNT_STATUSES +> -type AccountStatusesData = - RawSubscriptionData +type AccountStatusesData = RawSubscriptionData< + typeof SubscriptionTopic.ACCOUNT_STATUSES +> type AccountStatusesArgsDto = { start_block_id?: string @@ -34,7 +37,7 @@ type AccountStatusesDataDto = { } export const accountStatusesHandler = createSubscriptionHandler<{ - Topic: SubscriptionTopic.ACCOUNT_STATUSES + Topic: typeof SubscriptionTopic.ACCOUNT_STATUSES Args: AccountStatusesArgs Data: AccountStatusesData ArgsDto: AccountStatusesArgsDto diff --git a/packages/transport-http/src/subscribe/handlers/block-digests.ts b/packages/transport-http/src/subscribe/handlers/block-digests.ts index a29573bbe..5e9fa87cf 100644 --- a/packages/transport-http/src/subscribe/handlers/block-digests.ts +++ b/packages/transport-http/src/subscribe/handlers/block-digests.ts @@ -5,9 +5,11 @@ import { } from "@onflow/typedefs" import {BlockArgsDto, createSubscriptionHandler} from "./types" -type BlockDigestsArgs = SubscriptionArgs +type BlockDigestsArgs = SubscriptionArgs -type BlockDigestsData = RawSubscriptionData +type BlockDigestsData = RawSubscriptionData< + typeof SubscriptionTopic.BLOCK_DIGESTS +> type BlockDigestsDataDto = { block_id: string @@ -18,7 +20,7 @@ type BlockDigestsDataDto = { type BlockDigestsArgsDto = BlockArgsDto export const blockDigestsHandler = createSubscriptionHandler<{ - Topic: SubscriptionTopic.BLOCK_DIGESTS + Topic: typeof SubscriptionTopic.BLOCK_DIGESTS Args: BlockDigestsArgs Data: BlockDigestsData ArgsDto: BlockDigestsArgsDto diff --git a/packages/transport-http/src/subscribe/handlers/block-headers.ts b/packages/transport-http/src/subscribe/handlers/block-headers.ts index 0f24a2b74..5ff1fdeb8 100644 --- a/packages/transport-http/src/subscribe/handlers/block-headers.ts +++ b/packages/transport-http/src/subscribe/handlers/block-headers.ts @@ -5,9 +5,11 @@ import { } from "@onflow/typedefs" import {BlockArgsDto, createSubscriptionHandler} from "./types" -type BlockHeadersArgs = SubscriptionArgs +type BlockHeadersArgs = SubscriptionArgs -type BlockHeadersData = RawSubscriptionData +type BlockHeadersData = RawSubscriptionData< + typeof SubscriptionTopic.BLOCK_HEADERS +> type BlockHeadersArgsDto = BlockArgsDto @@ -20,7 +22,7 @@ type BlockHeadersDataDto = { } export const blockHeadersHandler = createSubscriptionHandler<{ - Topic: SubscriptionTopic.BLOCK_HEADERS + Topic: typeof SubscriptionTopic.BLOCK_HEADERS Args: BlockHeadersArgs Data: BlockHeadersData ArgsDto: BlockHeadersArgsDto diff --git a/packages/transport-http/src/subscribe/handlers/blocks.ts b/packages/transport-http/src/subscribe/handlers/blocks.ts index 793bfce94..903b6a5ae 100644 --- a/packages/transport-http/src/subscribe/handlers/blocks.ts +++ b/packages/transport-http/src/subscribe/handlers/blocks.ts @@ -5,9 +5,9 @@ import { } from "@onflow/typedefs" import {createSubscriptionHandler, BlockArgsDto} from "./types" -type BlocksArgs = SubscriptionArgs +type BlocksArgs = SubscriptionArgs -type BlocksData = RawSubscriptionData +type BlocksData = RawSubscriptionData type BlocksDataDto = { header: { @@ -30,7 +30,7 @@ type BlocksDataDto = { } export const blocksHandler = createSubscriptionHandler<{ - Topic: SubscriptionTopic.BLOCKS + Topic: typeof SubscriptionTopic.BLOCKS Args: BlocksArgs Data: BlocksData ArgsDto: BlockArgsDto diff --git a/packages/transport-http/src/subscribe/handlers/events.ts b/packages/transport-http/src/subscribe/handlers/events.ts index bb1998bd8..275f21c09 100644 --- a/packages/transport-http/src/subscribe/handlers/events.ts +++ b/packages/transport-http/src/subscribe/handlers/events.ts @@ -5,9 +5,9 @@ import { } from "@onflow/typedefs" import {createSubscriptionHandler} from "./types" -type EventsArgs = SubscriptionArgs +type EventsArgs = SubscriptionArgs -type EventsData = RawSubscriptionData +type EventsData = RawSubscriptionData export type EventsArgsDto = ( | { @@ -37,7 +37,7 @@ type EventsDataDto = { } export const eventsHandler = createSubscriptionHandler<{ - Topic: SubscriptionTopic.EVENTS + Topic: typeof SubscriptionTopic.EVENTS Args: EventsArgs Data: EventsData ArgsDto: EventsArgsDto diff --git a/packages/transport-http/src/subscribe/handlers/transaction-statuses.ts b/packages/transport-http/src/subscribe/handlers/transaction-statuses.ts index 2bd654344..52ad3b53c 100644 --- a/packages/transport-http/src/subscribe/handlers/transaction-statuses.ts +++ b/packages/transport-http/src/subscribe/handlers/transaction-statuses.ts @@ -2,24 +2,18 @@ import { RawSubscriptionData, SubscriptionArgs, SubscriptionTopic, + TransactionExecutionStatus, } from "@onflow/typedefs" import {createSubscriptionHandler} from "./types" import {Buffer} from "buffer" -const STATUS_MAP = { - UNKNOWN: 0, - PENDING: 1, - FINALIZED: 2, - EXECUTED: 3, - SEALED: 4, - EXPIRED: 5, -} - -type TransactionStatusesArgs = - SubscriptionArgs +type TransactionStatusesArgs = SubscriptionArgs< + typeof SubscriptionTopic.TRANSACTION_STATUSES +> -type TransactionStatusesData = - RawSubscriptionData +type TransactionStatusesData = RawSubscriptionData< + typeof SubscriptionTopic.TRANSACTION_STATUSES +> type TransactionStatusesArgsDto = { tx_id: string @@ -45,7 +39,7 @@ type TransactionStatusesDataDto = { } export const transactionStatusesHandler = createSubscriptionHandler<{ - Topic: SubscriptionTopic.TRANSACTION_STATUSES + Topic: typeof SubscriptionTopic.TRANSACTION_STATUSES Args: TransactionStatusesArgs Data: TransactionStatusesData ArgsDto: TransactionStatusesArgsDto @@ -64,8 +58,8 @@ export const transactionStatusesHandler = createSubscriptionHandler<{ transactionStatus: { blockId: data.transaction_result.block_id, status: - STATUS_MAP[ - data.transaction_result.status.toUpperCase() as keyof typeof STATUS_MAP + TransactionExecutionStatus[ + data.transaction_result.status.toUpperCase() as keyof typeof TransactionExecutionStatus ], statusString: data.transaction_result.status.toUpperCase(), statusCode: data.transaction_result.status_code, diff --git a/packages/transport-http/src/subscribe/models.ts b/packages/transport-http/src/subscribe/models.ts index 99f99a860..85cb66b2c 100644 --- a/packages/transport-http/src/subscribe/models.ts +++ b/packages/transport-http/src/subscribe/models.ts @@ -1,8 +1,11 @@ -export enum Action { - LIST_SUBSCRIPTIONS = "list_subscriptions", - SUBSCRIBE = "subscribe", - UNSUBSCRIBE = "unsubscribe", -} +export const Action = { + LIST_SUBSCRIPTIONS: "list_subscriptions", + SUBSCRIBE: "subscribe", + UNSUBSCRIBE: "unsubscribe", +} as const + +export type Action = (typeof Action)[keyof typeof Action] + export interface BaseMessageRequest { action: Action subscription_id: string @@ -18,31 +21,31 @@ export interface BaseMessageResponse { } export interface ListSubscriptionsMessageRequest extends BaseMessageRequest { - action: Action.LIST_SUBSCRIPTIONS + action: typeof Action.LIST_SUBSCRIPTIONS } export interface ListSubscriptionsMessageResponse extends BaseMessageResponse { - action: Action.LIST_SUBSCRIPTIONS + action: typeof Action.LIST_SUBSCRIPTIONS subscriptions?: SubscriptionEntry[] } export interface SubscribeMessageRequest extends BaseMessageRequest { - action: Action.SUBSCRIBE + action: typeof Action.SUBSCRIBE topic: string arguments: Record } export interface SubscribeMessageResponse extends BaseMessageResponse { - action: Action.SUBSCRIBE + action: typeof Action.SUBSCRIBE topic: string } export interface UnsubscribeMessageRequest extends BaseMessageRequest { - action: Action.UNSUBSCRIBE + action: typeof Action.UNSUBSCRIBE } export type UnsubscribeMessageResponse = BaseMessageResponse & { - action: Action.UNSUBSCRIBE + action: typeof Action.UNSUBSCRIBE id: string } diff --git a/packages/typedefs/src/fvm-errors.ts b/packages/typedefs/src/fvm-errors.ts index 9c5d68441..85d18d151 100644 --- a/packages/typedefs/src/fvm-errors.ts +++ b/packages/typedefs/src/fvm-errors.ts @@ -1,80 +1,84 @@ -export enum FvmErrorCode { +export const FvmErrorCode = { // We use -1 for unknown error in FCL because FVM defines error codes as uint16 // This means we have no risk of collision with FVM error codes - UNKNOWN_ERROR = -1, + UNKNOWN_ERROR: -1, // tx validation errors 1000 - 1049 // Deprecated: no longer in use - TX_VALIDATION_ERROR = 1000, + TX_VALIDATION_ERROR: 1000, // Deprecated: No longer used. - INVALID_TX_BYTE_SIZE_ERROR = 1001, + INVALID_TX_BYTE_SIZE_ERROR: 1001, // Deprecated: No longer used. - INVALID_REFERENCE_BLOCK_ERROR = 1002, + INVALID_REFERENCE_BLOCK_ERROR: 1002, // Deprecated: No longer used. - EXPIRED_TRANSACTION_ERROR = 1003, + EXPIRED_TRANSACTION_ERROR: 1003, // Deprecated: No longer used. - INVALID_SCRIPT_ERROR = 1004, + INVALID_SCRIPT_ERROR: 1004, // Deprecated: No longer used. - INVALID_GAS_LIMIT_ERROR = 1005, - INVALID_PROPOSAL_SIGNATURE_ERROR = 1006, - INVALID_PROPOSAL_SEQ_NUMBER_ERROR = 1007, - INVALID_PAYLOAD_SIGNATURE_ERROR = 1008, - INVALID_ENVELOPE_SIGNATURE_ERROR = 1009, + INVALID_GAS_LIMIT_ERROR: 1005, + INVALID_PROPOSAL_SIGNATURE_ERROR: 1006, + INVALID_PROPOSAL_SEQ_NUMBER_ERROR: 1007, + INVALID_PAYLOAD_SIGNATURE_ERROR: 1008, + INVALID_ENVELOPE_SIGNATURE_ERROR: 1009, // base errors 1050 - 1100 // Deprecated: No longer used. - FVM_INTERNAL_ERROR = 1050, - VALUE_ERROR = 1051, - INVALID_ARGUMENT_ERROR = 1052, - INVALID_ADDRESS_ERROR = 1053, - INVALID_LOCATION_ERROR = 1054, - ACCOUNT_AUTHORIZATION_ERROR = 1055, - OPERATION_AUTHORIZATION_ERROR = 1056, - OPERATION_NOT_SUPPORTED_ERROR = 1057, - BLOCK_HEIGHT_OUT_OF_RANGE_ERROR = 1058, + FVM_INTERNAL_ERROR: 1050, + VALUE_ERROR: 1051, + INVALID_ARGUMENT_ERROR: 1052, + INVALID_ADDRESS_ERROR: 1053, + INVALID_LOCATION_ERROR: 1054, + ACCOUNT_AUTHORIZATION_ERROR: 1055, + OPERATION_AUTHORIZATION_ERROR: 1056, + OPERATION_NOT_SUPPORTED_ERROR: 1057, + BLOCK_HEIGHT_OUT_OF_RANGE_ERROR: 1058, // execution errors 1100 - 1200 // Deprecated: No longer used. - EXECUTION_ERROR = 1100, - CADENCE_RUNTIME_ERROR = 1101, + EXECUTION_ERROR: 1100, + CADENCE_RUNTIME_ERROR: 1101, // Deprecated: No longer used. - ENCODING_UNSUPPORTED_VALUE = 1102, - STORAGE_CAPACITY_EXCEEDED = 1103, + ENCODING_UNSUPPORTED_VALUE: 1102, + STORAGE_CAPACITY_EXCEEDED: 1103, // Deprecated: No longer used. - GAS_LIMIT_EXCEEDED_ERROR = 1104, - EVENT_LIMIT_EXCEEDED_ERROR = 1105, - LEDGER_INTERACTION_LIMIT_EXCEEDED_ERROR = 1106, - STATE_KEY_SIZE_LIMIT_ERROR = 1107, - STATE_VALUE_SIZE_LIMIT_ERROR = 1108, - TRANSACTION_FEE_DEDUCTION_FAILED_ERROR = 1109, - COMPUTATION_LIMIT_EXCEEDED_ERROR = 1110, - MEMORY_LIMIT_EXCEEDED_ERROR = 1111, - COULD_NOT_DECODE_EXECUTION_PARAMETER_FROM_STATE = 1112, - SCRIPT_EXECUTION_TIMED_OUT_ERROR = 1113, - SCRIPT_EXECUTION_CANCELLED_ERROR = 1114, - EVENT_ENCODING_ERROR = 1115, - INVALID_INTERNAL_STATE_ACCESS_ERROR = 1116, + GAS_LIMIT_EXCEEDED_ERROR: 1104, + EVENT_LIMIT_EXCEEDED_ERROR: 1105, + LEDGER_INTERACTION_LIMIT_EXCEEDED_ERROR: 1106, + STATE_KEY_SIZE_LIMIT_ERROR: 1107, + STATE_VALUE_SIZE_LIMIT_ERROR: 1108, + TRANSACTION_FEE_DEDUCTION_FAILED_ERROR: 1109, + COMPUTATION_LIMIT_EXCEEDED_ERROR: 1110, + MEMORY_LIMIT_EXCEEDED_ERROR: 1111, + COULD_NOT_DECODE_EXECUTION_PARAMETER_FROM_STATE: 1112, + SCRIPT_EXECUTION_TIMED_OUT_ERROR: 1113, + SCRIPT_EXECUTION_CANCELLED_ERROR: 1114, + EVENT_ENCODING_ERROR: 1115, + INVALID_INTERNAL_STATE_ACCESS_ERROR: 1116, // 1117 was never deployed and is free to use - INSUFFICIENT_PAYER_BALANCE = 1118, + INSUFFICIENT_PAYER_BALANCE: 1118, // accounts errors 1200 - 1250 // Deprecated: No longer used. - ACCOUNT_ERROR = 1200, - ACCOUNT_NOT_FOUND_ERROR = 1201, - ACCOUNT_PUBLIC_KEY_NOT_FOUND_ERROR = 1202, - ACCOUNT_ALREADY_EXISTS_ERROR = 1203, + ACCOUNT_ERROR: 1200, + ACCOUNT_NOT_FOUND_ERROR: 1201, + ACCOUNT_PUBLIC_KEY_NOT_FOUND_ERROR: 1202, + ACCOUNT_ALREADY_EXISTS_ERROR: 1203, // Deprecated: No longer used. - FROZEN_ACCOUNT_ERROR = 1204, + FROZEN_ACCOUNT_ERROR: 1204, // Deprecated: No longer used. - ACCOUNT_STORAGE_NOT_INITIALIZED_ERROR = 1205, - ACCOUNT_PUBLIC_KEY_LIMIT_ERROR = 1206, + ACCOUNT_STORAGE_NOT_INITIALIZED_ERROR: 1205, + ACCOUNT_PUBLIC_KEY_LIMIT_ERROR: 1206, // contract errors 1250 - 1300 // Deprecated: No longer used. - CONTRACT_ERROR = 1250, - CONTRACT_NOT_FOUND_ERROR = 1251, + CONTRACT_ERROR: 1250, + CONTRACT_NOT_FOUND_ERROR: 1251, // Deprecated: No longer used. - CONTRACT_NAMES_NOT_FOUND_ERROR = 1252, + CONTRACT_NAMES_NOT_FOUND_ERROR: 1252, // fvm std lib errors 1300-1400 - EVM_EXECUTION_ERROR = 1300, -} + EVM_EXECUTION_ERROR: 1300, +} as const +/** + * Error codes defined by the Flow Virtual Machine (FVM) for various types of errors that can occur during transaction execution + */ +export type FvmErrorCode = (typeof FvmErrorCode)[keyof typeof FvmErrorCode] diff --git a/packages/typedefs/src/index.ts b/packages/typedefs/src/index.ts index 51aefd32b..f63244b27 100644 --- a/packages/typedefs/src/index.ts +++ b/packages/typedefs/src/index.ts @@ -60,19 +60,24 @@ export interface AccountKey { revoked: boolean } -export enum SignatureAlgorithm { - ECDSA_P256 = 1, - ECDSA_secp256k1 = 2, - BLS_BLS12_381 = 3, -} +export const SignatureAlgorithm = { + ECDSA_P256: 1, + ECDSA_secp256k1: 2, + BLS_BLS12_381: 3, +} as const -export enum HashAlgorithm { - SHA2_256 = 1, - SHA2_384 = 2, - SHA3_256 = 3, - SHA3_384 = 4, - KMAC128_BLS_BLS12_381 = 5, -} +export type SignatureAlgorithm = + (typeof SignatureAlgorithm)[keyof typeof SignatureAlgorithm] + +export const HashAlgorithm = { + SHA2_256: 1, + SHA2_384: 2, + SHA3_256: 3, + SHA3_384: 4, + KMAC128_BLS_BLS12_381: 5, +} as const + +export type HashAlgorithm = (typeof HashAlgorithm)[keyof typeof HashAlgorithm] export interface Block { /** @@ -303,8 +308,18 @@ export interface Service { * Service provider object */ provider: Provider - + /** + * Service parameters + */ params: Record + /** + * Service data + */ + data?: Record + /** + * Service headers + */ + headers?: Record } export interface Signature { /** @@ -403,14 +418,18 @@ export interface TransactionStatus { /** * The execution status of the transaction. */ -export enum TransactionExecutionStatus { - UNKNOWN = 0, - PENDING = 1, - FINALIZED = 2, - EXECUTED = 3, - SEALED = 4, - EXPIRED = 5, -} +export const TransactionExecutionStatus = { + UNKNOWN: 0, + PENDING: 1, + FINALIZED: 2, + EXECUTED: 3, + SEALED: 4, + EXPIRED: 5, +} as const + +export type TransactionExecutionStatus = + (typeof TransactionExecutionStatus)[keyof typeof TransactionExecutionStatus] + /* * The Provider type describes a Wallet Provider associated with a specific Service. */ @@ -447,6 +466,14 @@ export interface Provider { * Indicates whether the Wallet provider is installed (if applicable). */ is_installed?: boolean + /** + * Indicates whether the Wallet provider requires installation (if applicable). + */ + requires_install?: boolean + /** + * The install link for the Wallet provider. + */ + install_link?: string } export interface NodeVersionInfo { /** @@ -492,17 +519,44 @@ export interface StreamConnection { } export interface EventFilter { + /** + * The event types to listen for + */ eventTypes?: string[] + /** + * The addresses to listen for + */ addresses?: string[] + /** + * The contracts to listen for + */ contracts?: string[] + /** + * The block ID to start listening for events + */ startBlockId?: string + /** + * The block height to start listening for events + */ startHeight?: number + /** + * The interval in milliseconds to send a heartbeat to the Access Node + */ heartbeatInterval?: number } export interface BlockHeartbeat { + /** + * The ID of the block + */ blockId: string + /** + * The height of the block + */ blockHeight: number + /** + * The timestamp of the block + */ timestamp: string } diff --git a/packages/typedefs/src/interaction.ts b/packages/typedefs/src/interaction.ts index 3896cbde9..d5befdfdb 100644 --- a/packages/typedefs/src/interaction.ts +++ b/packages/typedefs/src/interaction.ts @@ -1,101 +1,286 @@ -export enum InteractionTag { - UNKNOWN = "UNKNOWN", - SCRIPT = "SCRIPT", - TRANSACTION = "TRANSACTION", - GET_TRANSACTION_STATUS = "GET_TRANSACTION_STATUS", - GET_ACCOUNT = "GET_ACCOUNT", - GET_EVENTS = "GET_EVENTS", - PING = "PING", - GET_TRANSACTION = "GET_TRANSACTION", - GET_BLOCK = "GET_BLOCK", - GET_BLOCK_HEADER = "GET_BLOCK_HEADER", - GET_COLLECTION = "GET_COLLECTION", - GET_NETWORK_PARAMETERS = "GET_NETWORK_PARAMETERS", - SUBSCRIBE_EVENTS = "SUBSCRIBE_EVENTS", - GET_NODE_VERSION_INFO = "GET_NODE_VERSION_INFO", -} +export const InteractionTag = { + UNKNOWN: "UNKNOWN", + SCRIPT: "SCRIPT", + TRANSACTION: "TRANSACTION", + GET_TRANSACTION_STATUS: "GET_TRANSACTION_STATUS", + GET_ACCOUNT: "GET_ACCOUNT", + GET_EVENTS: "GET_EVENTS", + PING: "PING", + GET_TRANSACTION: "GET_TRANSACTION", + GET_BLOCK: "GET_BLOCK", + GET_BLOCK_HEADER: "GET_BLOCK_HEADER", + GET_COLLECTION: "GET_COLLECTION", + GET_NETWORK_PARAMETERS: "GET_NETWORK_PARAMETERS", + SUBSCRIBE_EVENTS: "SUBSCRIBE_EVENTS", + GET_NODE_VERSION_INFO: "GET_NODE_VERSION_INFO", +} as const +/** + * Represents different types of interactions with the Flow blockchain + */ +export type InteractionTag = + (typeof InteractionTag)[keyof typeof InteractionTag] -export enum InteractionStatus { - BAD = "BAD", - OK = "OK", -} +export const InteractionStatus = { + BAD: "BAD", + OK: "OK", +} as const +/** + * Status of an interaction with the Flow blockchain + */ +export type InteractionStatus = + (typeof InteractionStatus)[keyof typeof InteractionStatus] -export enum TransactionRole { - AUTHORIZER = "authorizer", - PAYER = "payer", - PROPOSER = "proposer", -} +export const TransactionRole = { + AUTHORIZER: "authorizer", + PAYER: "payer", + PROPOSER: "proposer", +} as const +/** + * Represents different roles in a transaction + */ +export type TransactionRole = + (typeof TransactionRole)[keyof typeof TransactionRole] -export enum InteractionResolverKind { - ARGUMENT = "ARGUMENT", - ACCOUNT = "ACCOUNT", -} +export const InteractionResolverKind = { + ARGUMENT: "ARGUMENT", + ACCOUNT: "ACCOUNT", +} as const +/** + * Represents different kinds of interaction resolvers + */ +export type InteractionResolverKind = + (typeof InteractionResolverKind)[keyof typeof InteractionResolverKind] +/** + * Represents an account involved in an interaction + */ export interface InteractionAccount { - kind: InteractionResolverKind.ACCOUNT + kind: typeof InteractionResolverKind.ACCOUNT tempId: string + /** + * The address of the account + */ addr: string | null + /** + * The key ID used for signing + */ keyId: number | string | null + /** + * The sequence number for the account key + */ sequenceNum: number | null + /** + * The signature for the account + */ signature: string | null + /** + * Function used for signing + */ signingFunction: any | null + /** + * Resolver function for the account + */ resolve: any | null + /** + * Role of the account in the transaction + */ role: { + /** + * Whether this account is a proposer + */ proposer: boolean + /** + * Whether this account is an authorizer + */ authorizer: boolean + /** + * Whether this account is a payer + */ payer: boolean + /** + * Whether this account is a parameter + */ param?: boolean } + /** + * Authorization details for the account + */ authorization: any } +/** + * Represents an interaction with the Flow blockchain + */ export interface Interaction { + /** + * The type of interaction + */ tag: InteractionTag + /** + * Assigned values for the interaction + */ assigns: Record + /** + * The status of the interaction + */ status: InteractionStatus + /** + * Reason for the current status + */ reason: string | null + /** + * Accounts involved in the interaction + */ accounts: Record + /** + * Parameters for the interaction + */ params: Record + /** + * Arguments for the interaction + */ arguments: Record + /** + * Message details for the interaction + */ message: { + /** + * The Cadence code to execute + */ cadence: string + /** + * Reference block for the transaction + */ refBlock: string + /** + * Compute limit for the transaction + */ computeLimit: number + /** + * The proposer of the transaction + */ proposer: string + /** + * The payer of the transaction + */ payer: string + /** + * The authorizations for the transaction + */ authorizations: string[] + /** + * Parameters for the message + */ params: Record[] + /** + * Arguments for the message + */ arguments: string[] } + /** + * The proposer of the transaction + */ proposer: string | null + /** + * The authorizations for the transaction + */ authorizations: string[] + /** + * The payer(s) of the transaction + */ payer: string[] + /** + * Event-related information + */ events: { + /** + * The type of event to listen for + */ eventType: string | null + /** + * Start block for event listening + */ start: string | number | null + /** + * End block for event listening + */ end: string | number | null + /** + * Specific block IDs to listen for events + */ blockIds: string[] } + /** + * Transaction-related information + */ transaction: { + /** + * The ID of the transaction + */ id: string | null } + /** + * Block-related information + */ block: { + /** + * The ID of the block + */ id: string | null + /** + * The height of the block + */ height: string | number | null + /** + * Whether the block is sealed + */ isSealed: boolean | null } + /** + * Account-related information + */ account: { + /** + * The address of the account + */ addr: string | null } + /** + * Collection-related information + */ collection: { + /** + * The ID of the collection + */ id: string | null } + /** + * Event subscription information + */ subscribeEvents: { + /** + * The event types to subscribe to + */ eventTypes: string[] | null + /** + * The addresses to listen for events + */ addresses: string[] | null + /** + * The contracts to listen for events + */ contracts: string[] | null + /** + * The starting block ID for event subscription + */ startBlockId: string | null + /** + * The starting block height for event subscription + */ startHeight: number | null + /** + * The heartbeat interval for event subscription + */ heartbeatInterval: number | null } } diff --git a/packages/typedefs/src/subscriptions.ts b/packages/typedefs/src/subscriptions.ts index 3c73f8c64..adde2b729 100644 --- a/packages/typedefs/src/subscriptions.ts +++ b/packages/typedefs/src/subscriptions.ts @@ -9,25 +9,45 @@ import { TransactionStatus, } from "." -export enum SubscriptionTopic { - BLOCKS = "blocks", - BLOCK_HEADERS = "block_headers", - BLOCK_DIGESTS = "block_digests", - ACCOUNT_STATUSES = "account_statuses", - TRANSACTION_STATUSES = "transaction_statuses", - EVENTS = "events", -} +export const SubscriptionTopic = { + BLOCKS: "blocks", + BLOCK_HEADERS: "block_headers", + BLOCK_DIGESTS: "block_digests", + ACCOUNT_STATUSES: "account_statuses", + TRANSACTION_STATUSES: "transaction_statuses", + EVENTS: "events", +} as const +/** + * Represents different topics that can be subscribed to for real-time data from the Flow blockchain + */ +export type SubscriptionTopic = + (typeof SubscriptionTopic)[keyof typeof SubscriptionTopic] +/** + * The data returned by a subscription, which will vary depending on the topic + */ export type SubscriptionData = SubscriptionDataMap[T] +/** + * Raw data returned by a subscription, which will vary depending on the topic and is not decoded + */ export type RawSubscriptionData = RawSubscriptionDataMap[T] +/** + * Arguments for a subscription, which will vary depending on the topic + */ export type SubscriptionArgs = SubscriptionArgsMap[T] +/** + * A subscription object that allows managing the subscription lifecycle + */ export type Subscription = { + /** + * Function to unsubscribe from the subscription + */ unsubscribe: () => void } diff --git a/packages/typedefs/src/transport.ts b/packages/typedefs/src/transport.ts index b48fa7b5f..f544afb9a 100644 --- a/packages/typedefs/src/transport.ts +++ b/packages/typedefs/src/transport.ts @@ -100,7 +100,16 @@ type SubscribeFn = ( type SendFn = (ix: Interaction, context: IContext, opts: IOpts) => void +/** + * Transport interface for the Flow SDK that provides methods for sending interactions and subscribing to data + */ export type SdkTransport = { + /** + * Function to send an interaction to the Flow blockchain + */ send: SendFn + /** + * Function to subscribe to real-time data from the Flow blockchain + */ subscribe: SubscribeFn } diff --git a/packages/util-address/src/index.ts b/packages/util-address/src/index.ts index 0ece45dfe..83da0116f 100644 --- a/packages/util-address/src/index.ts +++ b/packages/util-address/src/index.ts @@ -1,3 +1,8 @@ +/** + * @description Removes 0x from address if present + * @param address - Flow address + * @returns Flow address without 0x prefix + */ export function sansPrefix(address: null): null export function sansPrefix(address: string): string export function sansPrefix(address: string | null): string | null @@ -11,6 +16,11 @@ export function sansPrefix(address: string | null): string | null { return address.replace(/^0x/, "").replace(/^Fx/, "") } +/** + * @description Adds 0x to address if not already present + * @param address - Flow address + * @returns Flow address with 0x prefix + */ export function withPrefix(address: null): null export function withPrefix(address: string): string export function withPrefix(address: string | null): string | null diff --git a/packages/util-logger/src/util-logger.ts b/packages/util-logger/src/util-logger.ts index 329eb6c92..8b7d37b05 100644 --- a/packages/util-logger/src/util-logger.ts +++ b/packages/util-logger/src/util-logger.ts @@ -13,13 +13,15 @@ export const setConfig = (_config: any) => { /** * The levels of the logger */ -export enum LEVELS { - debug = 5, - info = 4, - log = 3, - warn = 2, - error = 1, -} +export const LEVELS = { + debug: 5, + info: 4, + log: 3, + warn: 2, + error: 1, +} as const + +export type LEVELS = (typeof LEVELS)[keyof typeof LEVELS] /** * Builds a message formatted for the logger diff --git a/packages/util-rpc/src/rpc-client.ts b/packages/util-rpc/src/rpc-client.ts index c6f1f6dde..0cec4d9d1 100644 --- a/packages/util-rpc/src/rpc-client.ts +++ b/packages/util-rpc/src/rpc-client.ts @@ -12,9 +12,12 @@ export type RpcNotification

= { params: P } -enum ReservedRpcMethods { - HELLO = "rpc_hello", -} +const ReservedRpcMethods = { + HELLO: "rpc_hello", +} as const + +type ReservedRpcMethods = + (typeof ReservedRpcMethods)[keyof typeof ReservedRpcMethods] type RequestHandler = (params: T) => any type NotificationHandler = (params: T) => void diff --git a/packages/util-rpc/src/rpc-error.ts b/packages/util-rpc/src/rpc-error.ts index 2ab252c6e..ea8b3e5e8 100644 --- a/packages/util-rpc/src/rpc-error.ts +++ b/packages/util-rpc/src/rpc-error.ts @@ -1,10 +1,12 @@ -export enum RpcErrorCode { - INVALID_REQUEST = -32600, - METHOD_NOT_FOUND = -32601, - INVALID_PARAMS = -32602, - INTERNAL_ERROR = -32603, - PARSE_ERROR = -32700, -} +export const RpcErrorCode = { + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + PARSE_ERROR: -32700, +} as const + +export type RpcErrorCode = (typeof RpcErrorCode)[keyof typeof RpcErrorCode] export class RpcError extends Error { constructor( diff --git a/packages/util-template/src/template.ts b/packages/util-template/src/template.ts index e2a68ae2e..705fcd6e8 100644 --- a/packages/util-template/src/template.ts +++ b/packages/util-template/src/template.ts @@ -53,6 +53,38 @@ function recApply(d: T): (x: U) => string { * @param head - A string, template string array, or template function * @param rest - The rest of the arguments * @returns A template function + * + * @example + * import { template } from "@onflow/util-template" + * + * // String template + * const simpleTemplate = template("Hello, World!"); + * console.log(simpleTemplate()); // "Hello, World!" + * + * // Template literal with interpolation + * const name = "Alice"; + * const greeting = template`Hello, ${name}!`; + * console.log(greeting()); // "Hello, Alice!" + * + * // Cadence script template + * const cadenceScript = template` + * access(all) fun main(greeting: String): String { + * return greeting.concat(", from Flow!") + * } + * `; + * console.log(cadenceScript()); // The Cadence script as a string + * + * // Used with FCL for dynamic Cadence code + * import * as fcl from "@onflow/fcl"; + * + * const contractAddress = "0x123456789abcdef0"; + * const scriptTemplate = fcl.cadence` + * import MyContract from ${contractAddress} + * + * access(all) fun main(): String { + * return MyContract.getMessage() + * } + * `; */ export function template( head: string | TemplateStringsArray | ((x?: unknown) => string),