@openzeppelin/contracts-ui-builder-adapter-stellar
Version:
Stellar Adapter for Contracts UI Builder
385 lines (337 loc) • 12.6 kB
text/typescript
import {
Account,
Address,
BASE_FEE,
Contract,
Keypair,
nativeToScVal,
rpc as StellarRpc,
TransactionBuilder,
} from '@stellar/stellar-sdk';
import type {
ContractSchema,
FunctionParameter,
NetworkConfig,
StellarNetworkConfig,
} from '@openzeppelin/contracts-ui-builder-types';
import { logger, userRpcConfigService } from '@openzeppelin/contracts-ui-builder-utils';
import { parseStellarInput } from '../transform';
import { formatStellarFunctionResult } from '../transform/output-formatter';
import { convertStellarTypeToScValType } from '../utils';
import { isStellarViewFunction } from './view-checker';
/**
* Private helper to get a Soroban RPC Server instance for view queries.
* Prioritizes custom RPC configuration, then falls back to network default.
*/
function getSorobanRpcServer(networkConfig: StellarNetworkConfig): StellarRpc.Server {
// Check if there's a custom RPC configuration
const customRpcConfig = userRpcConfigService.getUserRpcConfig(networkConfig.id);
const rpcUrl = customRpcConfig?.url || networkConfig.sorobanRpcUrl;
if (!rpcUrl) {
throw new Error(`No Soroban RPC URL available for network ${networkConfig.name}`);
}
logger.info(
'getSorobanRpcServer',
`Creating Soroban RPC server for ${networkConfig.name} using RPC: ${rpcUrl}`
);
// Allow HTTP for localhost development
const allowHttp = new URL(rpcUrl).hostname === 'localhost';
return new StellarRpc.Server(rpcUrl, {
allowHttp,
});
}
/**
* Creates a dummy transaction to simulate a contract function call.
* This is used for view functions that don't modify state.
*/
async function createSimulationTransaction(
contractAddress: string,
functionName: string,
args: unknown[],
paramTypes: string[],
networkConfig: StellarNetworkConfig
): Promise<TransactionBuilder> {
try {
// Create a dummy source account for simulation
// We'll use a well-known testnet account for simulation
const dummyKeypair = Keypair.random();
const sourceAccount = new Account(dummyKeypair.publicKey(), '0');
// Create contract instance
const contract = new Contract(contractAddress);
// Convert args to ScVal with proper type hints
const scValArgs = args.map((arg, index) => {
const paramType = paramTypes[index];
if (!paramType) {
// Fallback to direct conversion if type info missing
return nativeToScVal(arg);
}
// Special cases that don't need type hints
if (paramType === 'Bool' || paramType === 'Bytes' || paramType.match(/^BytesN<\d+>$/)) {
return nativeToScVal(arg);
}
// Use common utility for type conversion with hints
const typeHint = convertStellarTypeToScValType(paramType);
return nativeToScVal(arg, { type: typeHint });
});
// Build the transaction for simulation
const transaction = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
networkPassphrase: networkConfig.networkPassphrase,
})
.addOperation(contract.call(functionName, ...scValArgs))
.setTimeout(30);
return transaction;
} catch (error) {
logger.error('createSimulationTransaction', 'Failed to create simulation transaction:', error);
throw new Error(`Failed to create simulation transaction: ${(error as Error).message}`);
}
}
/**
* Determines if a Stellar contract function modifies state by simulating it
* and checking for state changes.
*
* @param contractAddress Address of the contract.
* @param functionName Name of the function to check.
* @param networkConfig The specific network configuration.
* @param inputTypes Parameter types for the function (empty array for parameterless functions).
* @returns True if the function modifies state, false if it's read-only.
*/
export async function checkStellarFunctionStateMutability(
contractAddress: string,
functionName: string,
networkConfig: StellarNetworkConfig,
inputTypes: string[] = []
): Promise<boolean> {
logger.info(
'checkStellarFunctionStateMutability',
`Checking state mutability for function: ${functionName} on ${contractAddress}`
);
try {
// --- Validate Contract Address --- //
try {
Address.fromString(contractAddress);
} catch {
throw new Error(`Invalid Stellar contract address provided: ${contractAddress}`);
}
// --- Get Soroban RPC Server --- //
const rpcServer = getSorobanRpcServer(networkConfig);
// --- Create dummy parameters for simulation --- //
// For state mutability detection, we can use dummy values since we only care about state changes
const dummyArgs = inputTypes.map((paramType) => {
// Create minimal dummy values for different types
switch (paramType) {
case 'Bool':
return false;
case 'I32':
case 'U32':
case 'I64':
case 'U64':
case 'I128':
case 'U128':
case 'I256':
case 'U256':
return 0;
case 'String':
return '';
case 'Bytes':
return Buffer.alloc(0);
case 'Address':
// Use the contract address as a dummy address
return contractAddress;
default:
// For complex types, use null which often gets converted to appropriate defaults
return null;
}
});
// --- Create Simulation Transaction --- //
const transactionBuilder = await createSimulationTransaction(
contractAddress,
functionName,
dummyArgs,
inputTypes,
networkConfig
);
const transaction = transactionBuilder.build();
logger.debug(
'checkStellarFunctionStateMutability',
`[Check ${functionName}] Simulating transaction for state mutability check`
);
// --- Simulate Transaction --- //
let simulationResult: StellarRpc.Api.SimulateTransactionResponse;
try {
simulationResult = await rpcServer.simulateTransaction(transaction);
} catch (simulationError) {
logger.warn(
'checkStellarFunctionStateMutability',
`[Check ${functionName}] Simulation failed, assuming function modifies state:`,
simulationError
);
// If simulation fails, err on the side of caution and assume it modifies state
return true;
}
// --- Process Simulation Result --- //
if (StellarRpc.Api.isSimulationError(simulationResult)) {
logger.warn(
'checkStellarFunctionStateMutability',
`[Check ${functionName}] Simulation error, assuming function modifies state:`,
simulationResult.error
);
// If there's a simulation error, assume it modifies state for safety
return true;
}
// --- Check State Changes (following Laboratory approach) --- //
const hasStateChanges =
simulationResult.stateChanges && simulationResult.stateChanges.length > 0;
logger.info(
'checkStellarFunctionStateMutability',
`[Check ${functionName}] State mutability check complete:`,
{
hasStateChanges,
stateChangesCount: simulationResult.stateChanges?.length || 0,
modifiesState: Boolean(hasStateChanges),
}
);
return Boolean(hasStateChanges);
} catch (error) {
logger.warn(
'checkStellarFunctionStateMutability',
`Failed to check state mutability for ${functionName}, assuming it modifies state:`,
error
);
// If anything goes wrong, assume the function modifies state as a safe default
return true;
}
}
/**
* Core logic for querying a Stellar view function.
*
* @param contractAddress Address of the contract.
* @param functionId ID of the function to query.
* @param networkConfig The specific network configuration.
* @param params Raw parameters for the function call.
* @param contractSchema Optional pre-loaded contract schema.
* @param loadContractFn Function reference to load contract schema if not provided.
* @returns The decoded result of the view function call.
*/
export async function queryStellarViewFunction(
contractAddress: string,
functionId: string,
networkConfig: NetworkConfig,
params: unknown[] = [],
contractSchema?: ContractSchema,
loadContractFn?: (source: string) => Promise<ContractSchema>
): Promise<unknown> {
logger.info(
'queryStellarViewFunction',
`Querying Stellar view function: ${functionId} on ${contractAddress} (${networkConfig.name})`,
{ params }
);
if (networkConfig.ecosystem !== 'stellar') {
throw new Error('Invalid network configuration for Stellar query.');
}
const stellarConfig = networkConfig as StellarNetworkConfig;
try {
// --- Validate Contract Address --- //
try {
Address.fromString(contractAddress);
} catch {
throw new Error(`Invalid Stellar contract address provided: ${contractAddress}`);
}
// --- Get Soroban RPC Server --- //
const rpcServer = getSorobanRpcServer(stellarConfig);
// --- Get Schema & Function Details --- //
let schema = contractSchema;
if (!schema && loadContractFn) {
schema = await loadContractFn(contractAddress);
}
if (!schema) {
throw new Error(
`Contract schema not provided and loadContractFn not available for ${contractAddress}`
);
}
const functionDetails = schema.functions.find((fn) => fn.id === functionId);
if (!functionDetails) {
throw new Error(`Function with ID ${functionId} not found in contract schema.`);
}
if (!isStellarViewFunction(functionDetails)) {
throw new Error(`Function ${functionDetails.name} is not a view function.`);
}
// --- Parse Input Parameters --- //
const expectedInputs: readonly FunctionParameter[] = functionDetails.inputs;
if (params.length !== expectedInputs.length) {
throw new Error(
`Incorrect number of parameters provided for ${functionDetails.name}. Expected ${expectedInputs.length}, got ${params.length}.`
);
}
const args = expectedInputs.map((inputParam: FunctionParameter, index: number) => {
const rawValue = params[index];
return parseStellarInput(rawValue, inputParam.type);
});
logger.debug('queryStellarViewFunction', 'Parsed Args for contract call:', args);
// --- Create Simulation Transaction --- //
const paramTypes = expectedInputs.map((input) => input.type);
const transactionBuilder = await createSimulationTransaction(
contractAddress,
functionDetails.name,
args,
paramTypes,
stellarConfig
);
const transaction = transactionBuilder.build();
logger.debug(
'queryStellarViewFunction',
`[Query ${functionDetails.name}] Simulating transaction:`,
transaction.toXDR()
);
// --- Simulate Transaction --- //
let simulationResult: StellarRpc.Api.SimulateTransactionResponse;
try {
simulationResult = await rpcServer.simulateTransaction(transaction);
} catch (simulationError) {
logger.error(
'queryStellarViewFunction',
`[Query ${functionDetails.name}] Simulation failed:`,
simulationError
);
throw new Error(
`Soroban RPC simulation failed for ${functionDetails.name}: ${(simulationError as Error).message}`
);
}
// --- Process Simulation Result --- //
if (StellarRpc.Api.isSimulationError(simulationResult)) {
logger.error(
'queryStellarViewFunction',
`[Query ${functionDetails.name}] Simulation error:`,
simulationResult.error
);
throw new Error(`Contract simulation failed: ${simulationResult.error}`);
}
if (!simulationResult.result) {
throw new Error(`No result returned from contract simulation for ${functionDetails.name}`);
}
const rawResult = simulationResult.result.retval;
logger.debug(
'queryStellarViewFunction',
`[Query ${functionDetails.name}] Raw simulation result:`,
rawResult
);
// --- Format Result --- //
const formattedResult = formatStellarFunctionResult(rawResult, functionDetails);
logger.info(
'queryStellarViewFunction',
`[Query ${functionDetails.name}] Formatted result:`,
formattedResult
);
return formattedResult;
} catch (error) {
const errorMessage = `Failed to query Stellar view function ${functionId} on network ${networkConfig.name}: ${(error as Error).message}`;
logger.error('queryStellarViewFunction', errorMessage, {
contractAddress,
functionId,
params,
networkConfig,
error,
});
throw new Error(errorMessage);
}
}