@openzeppelin/contracts-ui-builder-adapter-stellar
Version:
Stellar Adapter for Contracts UI Builder
223 lines (180 loc) • 8.07 kB
text/typescript
import {
Account,
BASE_FEE,
Contract,
rpc as StellarRpc,
TransactionBuilder,
} from '@stellar/stellar-sdk';
import type {
ExecutionConfig,
StellarNetworkConfig,
TransactionStatusUpdate,
TxStatus,
} from '@openzeppelin/contracts-ui-builder-types';
import { logger, userRpcConfigService } from '@openzeppelin/contracts-ui-builder-utils';
import { valueToScVal } from '../transform/input-parser';
import { getStellarWalletConnectionStatus, signTransaction } from '../wallet/connection';
import { ExecutionStrategy } from './execution-strategy';
import type { StellarTransactionData } from './formatter';
const SYSTEM_LOG_TAG = 'EoaExecutionStrategy';
/**
* Get Soroban RPC Server instance with proper configuration
*/
function getSorobanRpcServer(networkConfig: StellarNetworkConfig): StellarRpc.Server {
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}`);
}
// Allow HTTP for localhost development
const allowHttp = new URL(rpcUrl).hostname === 'localhost';
return new StellarRpc.Server(rpcUrl, {
allowHttp,
});
}
/**
* Implements the ExecutionStrategy for a standard Externally Owned Account (EOA).
* This strategy involves signing and broadcasting a transaction directly from the user's
* connected Stellar wallet, which is the most common way of interacting with Stellar/Soroban contracts.
*/
export class EoaExecutionStrategy implements ExecutionStrategy {
public async execute(
transactionData: StellarTransactionData,
executionConfig: ExecutionConfig,
networkConfig: StellarNetworkConfig,
onStatusChange: (status: TxStatus, details: TransactionStatusUpdate) => void,
// runtimeApiKey is unused in EOA strategy but required by the interface
_runtimeApiKey?: string
): Promise<{ txHash: string }> {
logger.info(SYSTEM_LOG_TAG, 'Using Stellar EOA execution strategy');
// Validate execution config is for EOA
if (executionConfig.method !== 'eoa') {
throw new Error(`Expected EOA execution config, got: ${executionConfig.method}`);
}
return this.executeEoaTransaction(transactionData, networkConfig, onStatusChange);
}
private async executeEoaTransaction(
txData: StellarTransactionData,
stellarConfig: StellarNetworkConfig,
onStatusChange: (status: TxStatus, details: TransactionStatusUpdate) => void
): Promise<{ txHash: string }> {
try {
// --- Step 1: Get RPC Server and Connected Wallet Address --- //
const rpcServer = getSorobanRpcServer(stellarConfig);
const connectedAddress = this.getConnectedWalletAddress();
logger.info(SYSTEM_LOG_TAG, `Connected address: ${connectedAddress}`);
// --- Step 2: Get Account Details --- //
let sourceAccount: Account;
try {
const accountResponse = await rpcServer.getAccount(connectedAddress);
sourceAccount = new Account(connectedAddress, accountResponse.sequenceNumber());
} catch (error) {
throw new Error(`Failed to load account details: ${(error as Error).message}`);
}
// --- Step 3: Build Transaction --- //
const contract = new Contract(txData.contractAddress);
const transactionBuilder = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
networkPassphrase: stellarConfig.networkPassphrase,
});
// Add the contract call operation (convert args to ScVal with comprehensive type support)
const scValArgs = txData.args.map((arg, index) => {
const argType = txData.argTypes[index];
const argSchema = txData.argSchema?.[index]; // Pass schema for struct field type resolution
return valueToScVal(arg, argType, argSchema);
});
transactionBuilder.addOperation(contract.call(txData.functionName, ...scValArgs));
// Set timeout (default 30 seconds)
transactionBuilder.setTimeout(30);
let transaction = transactionBuilder.build();
// --- Step 4: Simulate Transaction First --- //
try {
const simulation = await rpcServer.simulateTransaction(transaction);
if (StellarRpc.Api.isSimulationError(simulation)) {
throw new Error(`Transaction simulation failed: ${simulation.error}`);
}
// Prepare the transaction with simulation results
transaction = await rpcServer.prepareTransaction(transaction);
} catch (error) {
throw new Error(`Transaction simulation/preparation failed: ${(error as Error).message}`);
}
onStatusChange('pendingSignature', {});
// --- Step 5: Sign Transaction --- //
try {
const signResult = await signTransaction(transaction.toXDR(), connectedAddress);
const signedTx = TransactionBuilder.fromXDR(
signResult.signedTxXdr,
stellarConfig.networkPassphrase
);
// Type guard to ensure we have a regular Transaction, not a FeeBumpTransaction
if ('memo' in signedTx && 'sequence' in signedTx) {
transaction = signedTx;
} else {
throw new Error('Unexpected transaction type returned from signing');
}
} catch (error) {
if ((error as Error).message.includes('User declined')) {
throw new Error('Transaction was rejected by user');
}
throw new Error(`Failed to sign transaction: ${(error as Error).message}`);
}
onStatusChange('pendingConfirmation', {});
// --- Step 6: Send Transaction --- //
let sendResult: StellarRpc.Api.SendTransactionResponse;
try {
sendResult = await rpcServer.sendTransaction(transaction);
} catch (error) {
throw new Error(`Failed to broadcast transaction: ${(error as Error).message}`);
}
if (sendResult.status !== 'PENDING') {
throw new Error(`Transaction failed to submit: ${sendResult.status}`);
}
const txHash = sendResult.hash;
logger.info(SYSTEM_LOG_TAG, `Transaction submitted successfully: ${txHash}`);
// Status remains 'pendingConfirmation' during confirmation wait
// --- Step 7: Wait for Confirmation --- //
try {
let txResponse;
const MAX_ATTEMPTS = 10;
let attempts = 0;
while (attempts++ < MAX_ATTEMPTS && txResponse?.status !== 'SUCCESS') {
await new Promise((resolve) => setTimeout(resolve, 1000));
txResponse = await rpcServer.getTransaction(txHash);
switch (txResponse.status) {
case 'FAILED':
throw new Error(`Transaction failed: ${JSON.stringify(txResponse.resultXdr)}`);
case 'NOT_FOUND':
continue;
case 'SUCCESS':
break;
default:
// Continue waiting
}
}
if (attempts >= MAX_ATTEMPTS || txResponse?.status !== 'SUCCESS') {
logger.warn(SYSTEM_LOG_TAG, `Transaction confirmation timeout for ${txHash}`);
// Don't throw error, just return the hash - transaction might still succeed
}
} catch (confirmError) {
// Log the error but don't fail the transaction - it was already submitted
logger.error(SYSTEM_LOG_TAG, 'Error waiting for confirmation:', confirmError);
}
onStatusChange('success', {
txHash,
});
return { txHash };
} catch (error) {
const errorMessage = `Failed to execute Stellar EOA transaction: ${(error as Error).message}`;
logger.error(SYSTEM_LOG_TAG, errorMessage, error);
onStatusChange('error', {});
throw new Error(errorMessage);
}
}
private getConnectedWalletAddress(): string {
const connectionStatus = getStellarWalletConnectionStatus();
if (!connectionStatus.isConnected || !connectionStatus.address) {
throw new Error('No connected wallet found. Please connect your Stellar wallet first.');
}
return connectionStatus.address;
}
}