@openzeppelin/contracts-ui-builder-adapter-stellar
Version:
Stellar Adapter for Contracts UI Builder
542 lines (494 loc) • 17.8 kB
text/typescript
import type React from 'react';
import type {
AvailableUiKit,
Connector,
ContractAdapter,
ContractFunction,
ContractSchema,
EcosystemReactUiProviderProps,
EcosystemSpecificReactHooks,
EcosystemWalletComponents,
ExecutionConfig,
ExecutionMethodDetail,
FieldType,
FormFieldType,
FunctionParameter,
NativeConfigLoader,
RelayerDetails,
RelayerDetailsRich,
StellarNetworkConfig,
TransactionStatusUpdate,
TxStatus,
UiKitConfiguration,
UserRpcProviderConfig,
WalletConnectionStatus,
} from '@openzeppelin/contracts-ui-builder-types';
import { isStellarNetworkConfig } from '@openzeppelin/contracts-ui-builder-types';
import { logger } from '@openzeppelin/contracts-ui-builder-utils';
// Import functions from modules
import { loadStellarContract, loadStellarContractWithMetadata } from './contract/loader';
import { StellarRelayerOptions } from './transaction/components';
import { RelayerExecutionStrategy } from './transaction/relayer';
import {
getStellarExplorerAddressUrl,
getStellarExplorerTxUrl,
getStellarSupportedExecutionMethods,
testStellarRpcConnection,
validateStellarExecutionConfig,
validateStellarRpcEndpoint,
} from './configuration';
import {
generateStellarDefaultField,
getStellarCompatibleFieldTypes,
mapStellarParameterTypeToFieldType,
} from './mapping';
import {
getStellarWritableFunctions,
isStellarViewFunction,
queryStellarViewFunction,
} from './query';
import { formatStellarTransactionData, signAndBroadcastStellarTransaction } from './transaction';
import { formatStellarFunctionResult } from './transform';
import { validateAndConvertStellarArtifacts } from './utils';
import { isValidAddress as isStellarValidAddress, type StellarAddressType } from './validation';
import {
connectStellarWallet,
disconnectStellarWallet,
generateStellarWalletsKitExportables,
getInitializedStellarWalletImplementation,
getResolvedWalletComponents,
getStellarAvailableConnectors,
getStellarWalletImplementation,
loadInitialConfigFromAppService,
resolveFullUiKitConfiguration,
stellarFacadeHooks,
stellarUiKitManager,
StellarWalletConnectionStatus,
StellarWalletUiRoot,
supportsStellarWalletConnection,
} from './wallet';
/**
* Stellar-specific adapter implementation using explicit method delegation.
*
* NOTE: Contains placeholder implementations for most functionalities.
*/
export class StellarAdapter implements ContractAdapter {
readonly networkConfig: StellarNetworkConfig;
readonly initialAppServiceKitName: UiKitConfiguration['kitName'];
constructor(networkConfig: StellarNetworkConfig) {
if (!isStellarNetworkConfig(networkConfig)) {
throw new Error('StellarAdapter requires a valid Stellar network configuration.');
}
this.networkConfig = networkConfig;
// Set the network config in the wallet UI kit manager
stellarUiKitManager.setNetworkConfig(networkConfig);
// Initialize wallet implementation with network config
getStellarWalletImplementation(networkConfig).catch((error) => {
logger.error(
'StellarAdapter:constructor',
'Failed to initialize wallet implementation:',
error
);
});
// Determine the initial kitName from AppConfigService at the time of adapter construction.
// This provides a baseline kitName preference from the application's static/global configuration.
// It defaults to 'custom' if no specific kitName is found in AppConfigService.
const initialGlobalConfig = loadInitialConfigFromAppService();
this.initialAppServiceKitName =
(initialGlobalConfig.kitName as UiKitConfiguration['kitName']) || 'custom';
logger.info(
'StellarAdapter:constructor',
'Initial kitName from AppConfigService noted:',
this.initialAppServiceKitName
);
logger.info(
'StellarAdapter',
`Adapter initialized for network: ${networkConfig.name} (ID: ${networkConfig.id})`
);
}
// --- Contract Loading --- //
/**
* NOTE about artifact inputs (single input with auto-detection):
*
* The Builder renders the contract definition step using whatever fields the
* adapter returns here. EVM uses one optional ABI field; Midnight provides
* multiple fields. Stellar should use a single input approach with automatic
* content detection when we add manual-spec support:
*
* - Keep `contractAddress` (required)
* - Add optional `contractDefinition` (type: `code-editor`, language: `json`)
* with file upload support for both JSON and Wasm binary content
*
* When this field is added:
* - Extend `validateAndConvertStellarArtifacts(...)` to accept
* `{ contractAddress, contractDefinition? }`
* - In the loader, branch: if `contractDefinition` provided, auto-detect
* content type (JSON vs Wasm using magic bytes `\0asm`):
* - For JSON: Parse and validate as Soroban spec, use `transformStellarSpecToSchema`
* - For Wasm: Extract embedded spec from binary, parse locally (no RPC)
* - Set `source: 'manual'` with `contractDefinitionOriginal` to the raw
* user-provided content. This ensures auto-save captures and restores the
* manual contract definition exactly like the EVM/Midnight flows.
* - Provide clear UI hints about supported formats (JSON spec or Wasm binary).
*/
public getContractDefinitionInputs(): FormFieldType[] {
return [
{
id: 'contractAddress',
name: 'contractAddress',
label: 'Contract ID',
type: 'blockchain-address',
validation: { required: true },
placeholder: 'C...',
helperText: 'Enter the Stellar contract ID (C...).',
},
];
}
/**
* @inheritdoc
*/
public async loadContract(source: string | Record<string, unknown>): Promise<ContractSchema> {
// Convert generic input to Stellar-specific artifacts
const artifacts = validateAndConvertStellarArtifacts(source);
const result = await loadStellarContract(artifacts, this.networkConfig);
return result.schema;
}
/**
* @inheritdoc
*/
public async loadContractWithMetadata(source: string | Record<string, unknown>): Promise<{
schema: ContractSchema;
source: 'fetched' | 'manual';
contractDefinitionOriginal?: string;
metadata?: {
fetchedFrom?: string;
contractName?: string;
fetchTimestamp?: Date;
definitionHash?: string;
};
}> {
try {
// Convert generic input to Stellar-specific artifacts
const artifacts = validateAndConvertStellarArtifacts(source);
const result = await loadStellarContractWithMetadata(artifacts, this.networkConfig);
return {
schema: result.schema,
source: result.source,
contractDefinitionOriginal: result.contractDefinitionOriginal,
metadata: result.metadata,
};
} catch (error) {
// Re-throw errors consistently with EVM adapter
throw error;
}
}
getWritableFunctions(contractSchema: ContractSchema): ContractSchema['functions'] {
return getStellarWritableFunctions(contractSchema);
}
// --- Type Mapping & Field Generation --- //
mapParameterTypeToFieldType(parameterType: string): FieldType {
return mapStellarParameterTypeToFieldType(parameterType);
}
getCompatibleFieldTypes(parameterType: string): FieldType[] {
return getStellarCompatibleFieldTypes(parameterType);
}
generateDefaultField<T extends FieldType = FieldType>(
parameter: FunctionParameter,
contractSchema?: ContractSchema
): FormFieldType<T> {
return generateStellarDefaultField(parameter, contractSchema);
}
// --- Transaction Formatting & Execution --- //
public formatTransactionData(
contractSchema: ContractSchema,
functionId: string,
submittedInputs: Record<string, unknown>,
fields: FormFieldType[]
): unknown {
return formatStellarTransactionData(contractSchema, functionId, submittedInputs, fields);
}
async signAndBroadcast(
transactionData: unknown,
executionConfig: ExecutionConfig,
onStatusChange: (status: TxStatus, details: TransactionStatusUpdate) => void,
runtimeApiKey?: string
): Promise<{ txHash: string }> {
return signAndBroadcastStellarTransaction(
transactionData,
executionConfig,
this.networkConfig,
onStatusChange,
runtimeApiKey
);
}
// NOTE: waitForTransactionConfirmation? is optional in the interface.
// Since the imported function is currently undefined, we omit the method here.
// If implemented in ./transaction/sender.ts later, add the method back:
// async waitForTransactionConfirmation?(...) { ... }
// --- View Function Querying --- //
isViewFunction(functionDetails: ContractFunction): boolean {
return isStellarViewFunction(functionDetails);
}
// Implement queryViewFunction with the correct signature from ContractAdapter
async queryViewFunction(
contractAddress: string,
functionId: string,
params: unknown[] = [],
contractSchema?: ContractSchema
): Promise<unknown> {
return queryStellarViewFunction(
contractAddress,
functionId,
this.networkConfig,
params,
contractSchema,
(address: string) => this.loadContract({ contractAddress: address })
);
}
formatFunctionResult(decodedValue: unknown, functionDetails: ContractFunction): string {
return formatStellarFunctionResult(decodedValue, functionDetails);
}
// --- Wallet Interaction --- //
supportsWalletConnection(): boolean {
return supportsStellarWalletConnection();
}
async getAvailableConnectors(): Promise<Connector[]> {
return getStellarAvailableConnectors();
}
async connectWallet(
connectorId: string
): Promise<{ connected: boolean; address?: string; error?: string }> {
return connectStellarWallet(connectorId);
}
async disconnectWallet(): Promise<{ disconnected: boolean; error?: string }> {
return disconnectStellarWallet();
}
getWalletConnectionStatus(): StellarWalletConnectionStatus {
const impl = getInitializedStellarWalletImplementation();
if (!impl) {
return {
isConnected: false,
address: undefined,
chainId: stellarUiKitManager.getState().networkConfig?.id || 'stellar-testnet',
};
}
const stellarStatus = impl.getWalletConnectionStatus();
// Return the rich Stellar-specific status directly
return stellarStatus;
}
/**
* @inheritdoc
*/
onWalletConnectionChange(
callback: (
currentStatus: WalletConnectionStatus,
previousStatus: WalletConnectionStatus
) => void
): () => void {
const walletImplementation = getInitializedStellarWalletImplementation();
if (!walletImplementation) {
logger.warn(
'StellarAdapter:onWalletConnectionChange',
'Wallet implementation not ready. Subscription may not work.'
);
return () => {};
}
return walletImplementation.onWalletConnectionChange(
(currentImplStatus, previousImplStatus) => {
callback(currentImplStatus, previousImplStatus);
}
);
}
// --- Configuration & Metadata --- //
async getSupportedExecutionMethods(): Promise<ExecutionMethodDetail[]> {
return getStellarSupportedExecutionMethods();
}
async validateExecutionConfig(config: ExecutionConfig): Promise<true | string> {
const walletStatus = this.getWalletConnectionStatus();
return validateStellarExecutionConfig(config, walletStatus);
}
// Implement getExplorerUrl with the correct signature from ContractAdapter
getExplorerUrl(address: string): string | null {
return getStellarExplorerAddressUrl(address, this.networkConfig);
}
// Implement getExplorerTxUrl with the correct signature from ContractAdapter
getExplorerTxUrl?(txHash: string): string | null {
if (getStellarExplorerTxUrl) {
return getStellarExplorerTxUrl(txHash, this.networkConfig);
}
return null;
}
// --- Validation --- //
isValidAddress(address: string, addressType?: string): boolean {
return isStellarValidAddress(address, addressType as StellarAddressType);
}
public async getAvailableUiKits(): Promise<AvailableUiKit[]> {
return [
{
id: 'custom',
name: 'Stellar Wallets Kit Custom',
configFields: [],
},
{
id: 'stellar-wallets-kit',
name: 'Stellar Wallets Kit',
configFields: [],
},
];
}
/**
* @inheritdoc
*/
public async configureUiKit(
programmaticOverrides: Partial<UiKitConfiguration> = {},
options?: {
loadUiKitNativeConfig?: NativeConfigLoader;
}
): Promise<void> {
const currentAppServiceConfig = loadInitialConfigFromAppService();
// Delegate the entire configuration resolution to the service function
const finalFullConfig = await resolveFullUiKitConfiguration(
programmaticOverrides,
this.initialAppServiceKitName,
currentAppServiceConfig,
options
);
// Configure the Stellar UI kit manager
await stellarUiKitManager.configure(finalFullConfig);
logger.info(
'StellarAdapter:configureUiKit',
'StellarUiKitManager configuration requested with final config:',
finalFullConfig
);
}
/**
* @inheritdoc
*/
public async getExportableWalletConfigFiles(
uiKitConfig?: UiKitConfiguration
): Promise<Record<string, string>> {
if (uiKitConfig?.kitName === 'stellar-wallets-kit') {
return generateStellarWalletsKitExportables(uiKitConfig);
}
return {};
}
/**
* @inheritdoc
*/
public getEcosystemWalletComponents(): EcosystemWalletComponents | undefined {
const currentManagerState = stellarUiKitManager.getState();
// Only attempt to resolve components if the manager has a configuration set
if (!currentManagerState.currentFullUiKitConfig) {
logger.debug(
'StellarAdapter:getEcosystemWalletComponents',
'No UI kit configuration available in manager yet. Returning undefined components.'
);
return undefined;
}
// Use the service to resolve components based on the current UI kit configuration
const components = getResolvedWalletComponents(currentManagerState.currentFullUiKitConfig);
return components;
}
/**
* @inheritdoc
*/
public getEcosystemReactUiContextProvider():
| React.ComponentType<EcosystemReactUiProviderProps>
| undefined {
logger.info(
'StellarAdapter:getEcosystemReactUiContextProvider',
'Returning StellarWalletUiRoot.'
);
return StellarWalletUiRoot;
}
/**
* @inheritdoc
*/
public getEcosystemReactHooks(): EcosystemSpecificReactHooks | undefined {
// Always provide hooks for Stellar adapter regardless of UI kit
return stellarFacadeHooks;
}
public async getRelayers(serviceUrl: string, accessToken: string): Promise<RelayerDetails[]> {
const relayerStrategy = new RelayerExecutionStrategy();
try {
return await relayerStrategy.getStellarRelayers(serviceUrl, accessToken, this.networkConfig);
} catch (error) {
logger.error('StellarAdapter', 'Failed to fetch Stellar relayers:', error);
return Promise.resolve([]);
}
}
public async getRelayer(
serviceUrl: string,
accessToken: string,
relayerId: string
): Promise<RelayerDetailsRich> {
const relayerStrategy = new RelayerExecutionStrategy();
try {
return await relayerStrategy.getStellarRelayer(
serviceUrl,
accessToken,
relayerId,
this.networkConfig
);
} catch (error) {
logger.error('StellarAdapter', 'Failed to fetch Stellar relayer details:', error);
return Promise.resolve({} as RelayerDetailsRich);
}
}
/**
* Returns a React component for configuring Stellar-specific relayer transaction options.
* @returns The Stellar relayer options component
*/
public getRelayerOptionsComponent():
| React.ComponentType<{
options: Record<string, unknown>;
onChange: (options: Record<string, unknown>) => void;
}>
| undefined {
return StellarRelayerOptions;
}
/**
* @inheritdoc
*/
public async validateRpcEndpoint(rpcConfig: UserRpcProviderConfig): Promise<boolean> {
// TODO: Implement Stellar-specific RPC validation when needed
return validateStellarRpcEndpoint(rpcConfig);
}
/**
* @inheritdoc
*/
public async testRpcConnection(rpcConfig: UserRpcProviderConfig): Promise<{
success: boolean;
latency?: number;
error?: string;
}> {
// TODO: Implement Stellar-specific RPC validation when needed
return testStellarRpcConnection(rpcConfig);
}
/**
* @inheritdoc
*/
public getUiLabels(): Record<string, string> | undefined {
return {
relayerConfigTitle: 'Transaction Configuration',
relayerConfigActiveDesc: 'Customize transaction parameters for submission',
relayerConfigInactiveDesc: 'Using recommended transaction configuration for reliability',
relayerConfigPresetTitle: 'Recommended Preset Active',
relayerConfigPresetDesc: 'Transactions will use recommended parameters for quick inclusion',
relayerConfigCustomizeBtn: 'Customize Settings',
detailsTitle: 'Relayer Details',
network: 'Network',
relayerId: 'Relayer ID',
active: 'Active',
paused: 'Paused',
systemDisabled: 'System Disabled',
balance: 'Balance',
// For Stellar, sequence number is conceptually similar; adapters supply the value
nonce: 'Sequence',
pending: 'Pending Transactions',
lastTransaction: 'Last Transaction',
};
}
}
// Also export as default to ensure compatibility with various import styles
export default StellarAdapter;