@openzeppelin/contracts-ui-builder-adapter-stellar
Version:
Stellar Adapter for Contracts UI Builder
569 lines (507 loc) • 20.7 kB
text/typescript
import {
Account,
BASE_FEE,
Contract,
rpc as StellarRpc,
TransactionBuilder,
xdr,
} from '@stellar/stellar-sdk';
import type {
ExecutionConfig,
RelayerDetails,
RelayerDetailsRich,
RelayerExecutionConfig,
StellarNetworkConfig,
TransactionStatusUpdate,
TxStatus,
} from '@openzeppelin/contracts-ui-builder-types';
import { logger } from '@openzeppelin/contracts-ui-builder-utils';
import {
Configuration,
RelayersApi,
type ApiResponseRelayerResponseData,
type ApiResponseRelayerStatusDataOneOf1,
type ScVal,
type StellarTransactionRequest,
type StellarTransactionResponse,
} from '@openzeppelin/relayer-sdk';
import { valueToScVal } from '../transform/input-parser';
import { getStellarWalletConnectionStatus, signTransaction } from '../wallet/connection';
import { ExecutionStrategy } from './execution-strategy';
import type { StellarTransactionData } from './formatter';
/**
* Stellar-specific transaction options for the OpenZeppelin Relayer.
* These options map directly to the StellarTransactionRequest parameters in the SDK.
*/
export interface StellarRelayerTransactionOptions {
// Basic options
maxFee?: number;
// Transaction expiration
validUntil?: string; // ISO 8601 date string
// Fee bump for stuck transactions
feeBump?: boolean;
}
/**
* Implements the ExecutionStrategy for the OpenZeppelin Relayer for Stellar networks.
* This strategy sends the transaction to the relayer service, which then handles
* fee payment, signing, and broadcasting on Stellar/Soroban. It includes a polling
* mechanism to wait for the transaction to be confirmed and return the final hash.
*/
export class RelayerExecutionStrategy implements ExecutionStrategy {
public async execute(
transactionData: StellarTransactionData,
executionConfig: ExecutionConfig,
networkConfig: StellarNetworkConfig,
onStatusChange: (status: TxStatus, details: TransactionStatusUpdate) => void,
runtimeApiKey?: string
): Promise<{ txHash: string }> {
const relayerConfig = executionConfig as RelayerExecutionConfig;
if (!runtimeApiKey) {
throw new Error('API Key is required for Relayer execution.');
}
const { transactionId } = await this.sendTransactionViaRelayer(
transactionData,
relayerConfig,
networkConfig,
runtimeApiKey
);
onStatusChange('pendingRelayer', { transactionId });
const sdkConfig = new Configuration({
basePath: relayerConfig.serviceUrl,
accessToken: runtimeApiKey,
});
const txHash = await this.pollForTransactionHash(
relayerConfig.relayer.relayerId,
transactionId,
sdkConfig
);
return { txHash };
}
/**
* Fetches and filters relayers for Stellar networks from the OpenZeppelin Relayer service.
* This function handles pagination to retrieve all available relayers.
*
* @param serviceUrl The base URL of the relayer service.
* @param accessToken The session-based API key for authentication.
* @param networkConfig The Stellar network configuration to filter relayers by.
* @returns A promise that resolves to an array of compatible relayer details.
* @throws If the API call fails or returns an unsuccessful response.
*/
public async getStellarRelayers(
serviceUrl: string,
accessToken: string,
networkConfig: StellarNetworkConfig
): Promise<RelayerDetails[]> {
logger.info(
'[StellarRelayer] Getting relayers with access token',
accessToken.slice(0, 5).padEnd(accessToken.length, '*')
);
const sdkConfig = new Configuration({
basePath: serviceUrl,
accessToken,
});
const relayersApi = new RelayersApi(sdkConfig);
let allRelayers: ApiResponseRelayerResponseData[] = [];
let currentPage = 1;
let totalItems = 0;
let hasMore = true;
do {
const { data } = await relayersApi.listRelayers(currentPage, 100);
if (!data.success || !data.data) {
throw new Error(`Failed to fetch relayers on page ${currentPage}.`);
}
allRelayers = [...allRelayers, ...data.data];
totalItems = data.pagination?.total_items || 0;
if (allRelayers.length >= totalItems) {
hasMore = false;
} else {
currentPage++;
}
} while (hasMore);
return allRelayers
.filter(
(r: ApiResponseRelayerResponseData) =>
r.network_type === 'stellar' && networkConfig.id.includes(r.network)
)
.map((r: ApiResponseRelayerResponseData) => ({
relayerId: r.id,
name: r.name,
address: r.address || '',
network: r.network,
paused: r.paused || false,
}));
}
/**
* Fetches comprehensive information about a specific Stellar relayer including balance and status.
* This function combines multiple SDK API calls to provide rich relayer details.
*
* @param serviceUrl The base URL of the relayer service.
* @param accessToken The session-based API key for authentication.
* @param relayerId The unique identifier of the relayer.
* @param networkConfig The Stellar network configuration for context.
* @returns A promise that resolves to enhanced relayer details including balance and status.
* @throws If any API call fails or returns an unsuccessful response.
*/
public async getStellarRelayer(
serviceUrl: string,
accessToken: string,
relayerId: string,
_networkConfig: StellarNetworkConfig
): Promise<RelayerDetailsRich> {
logger.info('[StellarRelayer] Getting detailed relayer info', relayerId);
const sdkConfig = new Configuration({
basePath: serviceUrl,
accessToken,
});
const relayersApi = new RelayersApi(sdkConfig);
try {
// Fetch basic relayer details, balance, and status in parallel
const [relayerResponse, balanceResponse, statusResponse] = await Promise.all([
relayersApi.getRelayer(relayerId),
relayersApi.getRelayerBalance(relayerId).catch((err) => {
logger.warn('[StellarRelayer] Failed to fetch balance', err);
return null;
}),
relayersApi.getRelayerStatus(relayerId).catch((err) => {
logger.warn('[StellarRelayer] Failed to fetch status', err);
return null;
}),
]);
if (!relayerResponse.data.success || !relayerResponse.data.data) {
throw new Error(`Failed to fetch relayer details for ID: ${relayerId}`);
}
const relayerData = relayerResponse.data.data;
// Build enhanced relayer details object
const enhancedDetails: RelayerDetailsRich = {
relayerId: relayerData.id,
name: relayerData.name,
address: relayerData.address || '',
network: relayerData.network,
paused: relayerData.paused || false,
systemDisabled: relayerData.system_disabled || false,
};
// Add balance if available (Stellar native balance in lumens)
if (balanceResponse?.data?.success && balanceResponse.data.data?.balance) {
try {
// Stellar balance is in stroops (1 XLM = 10,000,000 stroops)
const balanceInStroops = Number(balanceResponse.data.data.balance);
const balanceInXlm = balanceInStroops / 10000000;
enhancedDetails.balance = `${balanceInXlm.toFixed(7)} XLM`;
} catch (error) {
logger.warn('[StellarRelayer] Failed to format balance, using raw value', String(error));
enhancedDetails.balance = String(balanceResponse.data.data.balance);
}
}
// Add status details if available
if (statusResponse?.data?.success && statusResponse.data.data) {
const statusData = statusResponse.data.data;
if (statusData.network_type === 'stellar') {
// Type guard to ensure we have Stellar-specific fields
const stellarStatusData = statusData as ApiResponseRelayerStatusDataOneOf1;
if (
stellarStatusData.sequence_number !== undefined &&
stellarStatusData.sequence_number !== null
) {
enhancedDetails.nonce = String(stellarStatusData.sequence_number);
}
if (stellarStatusData.pending_transactions_count !== undefined) {
enhancedDetails.pendingTransactionsCount = stellarStatusData.pending_transactions_count;
}
if (stellarStatusData.last_confirmed_transaction_timestamp) {
enhancedDetails.lastConfirmedTransactionTimestamp =
stellarStatusData.last_confirmed_transaction_timestamp;
}
}
}
logger.info(
'[StellarRelayer] Retrieved enhanced relayer details',
JSON.stringify(enhancedDetails)
);
return enhancedDetails;
} catch (error) {
logger.error(
'[StellarRelayer] Failed to get relayer details',
error instanceof Error ? error.message : String(error)
);
throw error;
}
}
/**
* Submits a Stellar transaction to the relayer service for asynchronous processing.
* @param transactionData The Stellar contract transaction data.
* @param executionConfig The relayer-specific execution configuration.
* @param networkConfig The Stellar network configuration.
* @param runtimeApiKey The user's session-only API key.
* @returns A promise that resolves to an object containing the transaction ID assigned by the relayer.
*/
private async sendTransactionViaRelayer(
transactionData: StellarTransactionData,
executionConfig: RelayerExecutionConfig,
_networkConfig: StellarNetworkConfig,
runtimeApiKey: string
): Promise<{ transactionId: string }> {
// Type-safe extraction of Stellar-specific options
const stellarOptions = executionConfig.transactionOptions as
| StellarRelayerTransactionOptions
| undefined;
// If fee bump is requested, use signed XDR mode per relayer docs
// Only valid when providing a signed transaction_xdr
let relayerTxRequest: StellarTransactionRequest;
if (stellarOptions?.feeBump) {
const signedInnerXdr = await this.buildSignedInnerTransactionXdr(
transactionData,
_networkConfig,
stellarOptions
);
relayerTxRequest = {
network: executionConfig.relayer.network,
transaction_xdr: signedInnerXdr,
fee_bump: true,
...(stellarOptions?.maxFee !== undefined && { max_fee: stellarOptions.maxFee }),
...(stellarOptions?.validUntil !== undefined && { valid_until: stellarOptions.validUntil }),
};
} else {
// Default operations-based mode
relayerTxRequest = {
network: executionConfig.relayer.network, // Use relayer's network (e.g., 'testnet', 'mainnet')
source_account: executionConfig.relayer.address, // Use relayer's address as source account
operations: [
{
type: 'invoke_contract',
contract_address: transactionData.contractAddress,
function_name: transactionData.functionName,
args: this.convertArgsToScVal(transactionData),
// No auth field needed - using source_account at top level
},
],
// Include optional parameters if provided
...(stellarOptions?.maxFee !== undefined && { max_fee: stellarOptions.maxFee }),
...(stellarOptions?.validUntil !== undefined && { valid_until: stellarOptions.validUntil }),
// Note: fee_bump is not supported by the relayer service in operations mode
// Memos are not supported for Soroban contract operations
};
}
const sdkConfig = new Configuration({
basePath: executionConfig.serviceUrl,
accessToken: runtimeApiKey,
});
const relayersApi = new RelayersApi(sdkConfig);
const result = await relayersApi.sendTransaction(
executionConfig.relayer.relayerId,
relayerTxRequest
);
if (!result.data.success || !result.data.data?.id) {
throw new Error(`Relayer API failed to return a transaction ID. Error: ${result.data.error}`);
}
return { transactionId: result.data.data.id };
}
/**
* Converts Stellar transaction arguments to ScVal format for the relayer.
* Uses the same comprehensive conversion utility as the EOA execution strategy.
*/
private convertArgsToScVal(transactionData: StellarTransactionData): ScVal[] {
// Use the same comprehensive conversion as EOA execution strategy
return transactionData.args.map((arg, index) => {
const argType = transactionData.argTypes[index];
const argSchema = transactionData.argSchema?.[index]; // Pass schema for struct field type resolution
const scVal = valueToScVal(arg, argType, argSchema);
// Convert Stellar SDK ScVal to relayer SDK ScVal format
return this.stellarScValToRelayerScVal(scVal);
});
}
/**
* Build and sign the inner transaction using the connected wallet.
* Returns the signed inner transaction XDR to be wrapped by the relayer as a fee bump.
*/
private async buildSignedInnerTransactionXdr(
txData: StellarTransactionData,
stellarConfig: StellarNetworkConfig,
_options?: StellarRelayerTransactionOptions
): Promise<string> {
const rpcServer = this.getSorobanRpcServer(stellarConfig);
const connectedAddress = this.getConnectedWalletAddress();
// Fetch sequence for the connected address
const accountResponse = await rpcServer.getAccount(connectedAddress);
const sourceAccount = new Account(connectedAddress, accountResponse.sequenceNumber());
// Build a contract invocation transaction
const contract = new Contract(txData.contractAddress);
const transactionBuilder = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
networkPassphrase: stellarConfig.networkPassphrase,
});
const scValArgs = txData.args.map((arg, index) => {
const argType = txData.argTypes[index];
const argSchema = txData.argSchema?.[index];
return valueToScVal(arg, argType, argSchema);
});
transactionBuilder.addOperation(contract.call(txData.functionName, ...scValArgs));
// Note: Soroban contract transactions do not support memos; do not attach
// Timeout: keep short; relayer will wrap in fee bump and submit
transactionBuilder.setTimeout(30);
// Build → simulate → prepare
let transaction = transactionBuilder.build();
const simulation = await rpcServer.simulateTransaction(transaction);
if (StellarRpc.Api.isSimulationError(simulation)) {
throw new Error(`Transaction simulation failed: ${simulation.error}`);
}
transaction = await rpcServer.prepareTransaction(transaction);
// Sign with connected wallet
const signResult = await signTransaction(transaction.toXDR(), connectedAddress);
const signedTx = TransactionBuilder.fromXDR(
signResult.signedTxXdr,
stellarConfig.networkPassphrase
);
// Ensure we have a standard Transaction (not FeeBumpTransaction)
if ('memo' in signedTx && 'sequence' in signedTx) {
return signedTx.toXDR();
}
throw new Error('Unexpected transaction type returned from signing');
}
/**
* Get Soroban RPC Server instance with current configuration and user overrides.
*/
private getSorobanRpcServer(networkConfig: StellarNetworkConfig): StellarRpc.Server {
// Allow HTTP for localhost development
const rpcUrl = networkConfig.sorobanRpcUrl;
if (!rpcUrl) {
throw new Error(`No Soroban RPC URL available for network ${networkConfig.name}`);
}
const allowHttp = new URL(rpcUrl).hostname === 'localhost';
return new StellarRpc.Server(rpcUrl, { allowHttp });
}
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;
}
/**
* Converts a Stellar SDK ScVal to the relayer SDK ScVal format.
* The relayer SDK uses a simplified ScVal representation compared to Stellar SDK's XDR types.
*/
private stellarScValToRelayerScVal(stellarScVal: xdr.ScVal): ScVal {
const scValType = stellarScVal.switch();
switch (scValType.name) {
case 'scvBool':
return { bool: stellarScVal.b() };
case 'scvVoid':
return { bool: false }; // Fallback for void
case 'scvU32':
return { u32: stellarScVal.u32() };
case 'scvI32':
return { i32: stellarScVal.i32() };
case 'scvU64':
return { u64: stellarScVal.u64().toString() };
case 'scvI64':
return { i64: stellarScVal.i64().toString() };
case 'scvU128': {
const u128Parts = stellarScVal.u128();
return {
u128: {
hi: u128Parts.hi().toString(),
lo: u128Parts.lo().toString(),
},
};
}
case 'scvI128': {
const i128Parts = stellarScVal.i128();
return {
i128: {
hi: i128Parts.hi().toString(),
lo: i128Parts.lo().toString(),
},
};
}
case 'scvU256': {
const u256Parts = stellarScVal.u256();
return {
u256: {
hi_hi: u256Parts.hiHi().toString(),
hi_lo: u256Parts.hiLo().toString(),
lo_hi: u256Parts.loHi().toString(),
lo_lo: u256Parts.loLo().toString(),
},
};
}
case 'scvI256': {
const i256Parts = stellarScVal.i256();
return {
i256: {
hi_hi: i256Parts.hiHi().toString(),
hi_lo: i256Parts.hiLo().toString(),
lo_hi: i256Parts.loHi().toString(),
lo_lo: i256Parts.loLo().toString(),
},
};
}
case 'scvBytes':
return { bytes: stellarScVal.bytes().toString('hex') };
case 'scvString':
return { string: stellarScVal.str().toString() };
case 'scvSymbol':
return { symbol: stellarScVal.sym().toString() };
case 'scvVec':
return {
vec: stellarScVal.vec()?.map((val) => this.stellarScValToRelayerScVal(val)) || [],
};
case 'scvMap': {
const mapEntries = stellarScVal.map() || [];
return {
map: mapEntries.map((entry) => ({
key: this.stellarScValToRelayerScVal(entry.key()),
val: this.stellarScValToRelayerScVal(entry.val()),
})),
};
}
case 'scvAddress':
return { address: stellarScVal.address().toString() };
default:
// For any unhandled types, convert to string representation as fallback
return { string: stellarScVal.toString() };
}
}
/**
* Polls the relayer for a Stellar transaction's status until it is confirmed and has a hash, or fails.
* @param relayerId The ID of the relayer processing the transaction.
* @param transactionId The ID of the transaction to poll.
* @param sdkConfig The SDK configuration containing the necessary authentication.
* @returns A promise that resolves to the final transaction hash.
* @throws If the transaction fails or polling times out.
*/
private async pollForTransactionHash(
relayerId: string,
transactionId: string,
sdkConfig: Configuration
): Promise<string> {
const relayersApi = new RelayersApi(sdkConfig);
const POLLING_INTERVAL = 2000;
const POLLING_TIMEOUT = 300000; // 5 minutes in milliseconds
const startTime = Date.now();
while (Date.now() - startTime < POLLING_TIMEOUT) {
const { data } = await relayersApi.getTransactionById(relayerId, transactionId);
if (!data.success || !data.data) {
throw new Error(`Failed to get transaction status for ID: ${transactionId}`);
}
const txResponse = data.data as StellarTransactionResponse;
if (txResponse.status === 'mined' || txResponse.status === 'confirmed') {
if (!txResponse.hash) {
throw new Error(
`Transaction is confirmed but no hash was returned for ID: ${transactionId}`
);
}
return txResponse.hash;
}
if (
txResponse.status === 'failed' ||
txResponse.status === 'canceled' ||
txResponse.status === 'expired'
) {
throw new Error(`Transaction ${txResponse.status}`);
}
// Continue polling for 'pending' or 'sent' statuses
await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL));
}
throw new Error(`Polling for transaction hash timed out for ID: ${transactionId}`);
}
}