feat: expand non-evm relay and route planning support
This commit is contained in:
11
services/solana-relay/.env.example
Normal file
11
services/solana-relay/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
SOLANA_RELAY_RUNTIME_CONFIG=../../config/solana-relay-runtime.json
|
||||
SOLANA_OPERATOR_HOST=192.168.11.11
|
||||
SOLANA_CLUSTER=mainnet-beta
|
||||
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
|
||||
SOLANA_KEYPAIR_PATH=/root/.config/solana/id.json
|
||||
SOLANA_OPERATOR_WALLET=26HRdYbob5LmVM638jfpDd6ELyHz6TqP25YHVd9dGT4A
|
||||
SOLANA_DESTINATION_WALLET=26HRdYbob5LmVM638jfpDd6ELyHz6TqP25YHVd9dGT4A
|
||||
SOLANA_ADAPTER_ADDRESS=
|
||||
SOLANA_MIN_FINALITY_SLOTS=32
|
||||
CHAIN138_RPC_URL=http://192.168.11.211:8545
|
||||
CHAIN_REGISTRY_ADDRESS=0x6949137625CA923A4e9C80D5bc7DF673f9bbb84F
|
||||
13
services/solana-relay/package.json
Normal file
13
services/solana-relay/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "solana-relay",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"relay": "node src/relay-worker.mjs",
|
||||
"relay:once": "node src/relay-worker.mjs --once"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.6.1",
|
||||
"ethers": "^6.15.0"
|
||||
}
|
||||
}
|
||||
116
services/solana-relay/src/SolanaRelayService.ts
Normal file
116
services/solana-relay/src/SolanaRelayService.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
NON_EVM_RELAY_LIFECYCLE,
|
||||
type NonEvmNetworkPolicy,
|
||||
type NonEvmRelayObservation
|
||||
} from '../../non-evm-relay/lifecycle';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const SOLANA_RELAY_POLICY: NonEvmNetworkPolicy = {
|
||||
identifier: 'Solana',
|
||||
relayMode: 'custom_relay',
|
||||
destinationProgramModel: 'spl_or_bridge_wrapped_cw',
|
||||
signerFundingPolicy: 'sol_operator_signer',
|
||||
finalityPolicy: 'slot_finality>=32',
|
||||
publicExposureStatus: 'live'
|
||||
};
|
||||
|
||||
export interface SolanaRelayAssetRuntime {
|
||||
chain138Symbol: string;
|
||||
sourceToken: string;
|
||||
solanaSymbol: string;
|
||||
solanaMint: string;
|
||||
decimals: number;
|
||||
smokeTestAmountRaw: string;
|
||||
}
|
||||
|
||||
export interface SolanaRelayRuntimeConfig {
|
||||
sourceChain: {
|
||||
chainId: number;
|
||||
chainRegistryAddress: string;
|
||||
registryIdentifier: string;
|
||||
adapterAddress: string | null;
|
||||
};
|
||||
solana: {
|
||||
cluster: string;
|
||||
rpcUrl: string;
|
||||
operatorWallet: string;
|
||||
confirmationFinality: number;
|
||||
recipientEncoding: string;
|
||||
};
|
||||
relay: {
|
||||
mode: string;
|
||||
runtimeConfigEnv: string;
|
||||
syntheticConfirmationPrefix: string;
|
||||
publicExposureStatus?: string;
|
||||
workerEntryPoint?: string;
|
||||
};
|
||||
smokeTests: {
|
||||
defaultAssets: string[];
|
||||
wethRawAmount: string;
|
||||
gruRawAmount: string;
|
||||
requireAdapterRegistration: boolean;
|
||||
};
|
||||
assets: SolanaRelayAssetRuntime[];
|
||||
}
|
||||
|
||||
export function resolveSolanaRelayRuntimeConfigPath(): string {
|
||||
const explicit = process.env.SOLANA_RELAY_RUNTIME_CONFIG;
|
||||
if (explicit && explicit.trim() !== '') {
|
||||
return path.resolve(process.cwd(), explicit);
|
||||
}
|
||||
|
||||
return path.resolve(__dirname, '../../../../config/solana-relay-runtime.json');
|
||||
}
|
||||
|
||||
export function loadSolanaRelayRuntimeConfig(): SolanaRelayRuntimeConfig {
|
||||
const runtimePath = resolveSolanaRelayRuntimeConfigPath();
|
||||
const raw = fs.readFileSync(runtimePath, 'utf8');
|
||||
return JSON.parse(raw) as SolanaRelayRuntimeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared lifecycle surface for the Solana relay worker.
|
||||
* The actual host-side worker entry point lives in relay-worker.mjs and performs
|
||||
* real Solana settlement plus Chain 138 finalization.
|
||||
*/
|
||||
export class SolanaRelayService {
|
||||
readonly lifecycle = NON_EVM_RELAY_LIFECYCLE;
|
||||
readonly runtime: SolanaRelayRuntimeConfig;
|
||||
|
||||
constructor(runtime = loadSolanaRelayRuntimeConfig()) {
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
recordObservation(observation: NonEvmRelayObservation): NonEvmRelayObservation {
|
||||
return {
|
||||
...observation,
|
||||
lifecycleStep: observation.lifecycleStep
|
||||
};
|
||||
}
|
||||
|
||||
getAsset(symbol: string): SolanaRelayAssetRuntime | undefined {
|
||||
return this.runtime.assets.find((asset) => asset.solanaSymbol === symbol || asset.chain138Symbol === symbol);
|
||||
}
|
||||
|
||||
buildSmokeObservation(
|
||||
requestId: string,
|
||||
symbol: string,
|
||||
destinationReference?: string
|
||||
): NonEvmRelayObservation {
|
||||
const asset = this.getAsset(symbol);
|
||||
const destination = destinationReference || asset?.solanaMint || this.runtime.solana.operatorWallet;
|
||||
|
||||
return {
|
||||
requestId,
|
||||
lifecycleStep: 'confirm_finalize',
|
||||
destinationReference: destination,
|
||||
fulfillmentId: `${this.runtime.relay.syntheticConfirmationPrefix}:${symbol}:${requestId}`,
|
||||
finalityValue: this.runtime.solana.confirmationFinality,
|
||||
replayRejected: false
|
||||
};
|
||||
}
|
||||
}
|
||||
239
services/solana-relay/src/relay-worker.mjs
Normal file
239
services/solana-relay/src/relay-worker.mjs
Normal file
@@ -0,0 +1,239 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import process from 'process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const serviceRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(serviceRoot, '..', '..', '..');
|
||||
const stateDir = path.join(serviceRoot, 'state');
|
||||
const defaultStatePath = path.join(stateDir, 'relay-state.json');
|
||||
const defaultHealthPath = path.join(repoRoot, 'reports', 'status', 'solana-relay-worker-health.json');
|
||||
|
||||
dotenv.config({ path: path.join(repoRoot, '.env') });
|
||||
dotenv.config({ path: path.join(repoRoot, 'smom-dbis-138/.env') });
|
||||
dotenv.config({ path: path.join(process.env.HOME || '', '.secure-secrets', 'private-keys.env'), override: true });
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function loadRuntime() {
|
||||
const runtimePath = process.env.SOLANA_RELAY_RUNTIME_CONFIG
|
||||
? path.resolve(process.cwd(), process.env.SOLANA_RELAY_RUNTIME_CONFIG)
|
||||
: path.join(repoRoot, 'config', 'solana-relay-runtime.json');
|
||||
return {
|
||||
path: runtimePath,
|
||||
config: JSON.parse(fs.readFileSync(runtimePath, 'utf8'))
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAddress(value) {
|
||||
return ethers.getAddress(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function rawToUiAmount(rawValue, decimals) {
|
||||
const raw = BigInt(rawValue);
|
||||
const base = 10n ** BigInt(decimals);
|
||||
const whole = raw / base;
|
||||
const fraction = raw % base;
|
||||
if (fraction === 0n) return whole.toString();
|
||||
const fractionString = fraction.toString().padStart(decimals, '0').replace(/0+$/, '');
|
||||
return `${whole.toString()}.${fractionString}`;
|
||||
}
|
||||
|
||||
function decodeRecipient(destination, recipientBytes) {
|
||||
if (destination && destination.trim() !== '') return destination.trim();
|
||||
if (!recipientBytes || recipientBytes === '0x') return null;
|
||||
return ethers.toUtf8String(recipientBytes).trim();
|
||||
}
|
||||
|
||||
function runLocalCommand(command, args) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: process.env
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function ensureAta(mint, wallet, keypairPath) {
|
||||
try {
|
||||
runLocalCommand('spl-token', ['create-account', mint, '--owner', wallet, '--fee-payer', keypairPath]);
|
||||
} catch {
|
||||
// No-op when the associated token account already exists.
|
||||
}
|
||||
}
|
||||
|
||||
function mintToDestination(mint, uiAmount, wallet, keypairPath) {
|
||||
ensureAta(mint, wallet, keypairPath);
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const output = runLocalCommand('spl-token', [
|
||||
'mint',
|
||||
mint,
|
||||
uiAmount,
|
||||
'--mint-authority',
|
||||
keypairPath,
|
||||
'--recipient-owner',
|
||||
wallet
|
||||
]);
|
||||
const match = output.match(/Signature:\s+(\S+)/);
|
||||
if (match) return match[1];
|
||||
} catch {
|
||||
// Retry with a fresh blockhash on transient Solana RPC errors.
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to parse Solana mint signature for ${mint}`);
|
||||
}
|
||||
|
||||
function confirmSolanaSignature(signature) {
|
||||
try {
|
||||
return runLocalCommand('solana', ['confirm', signature, '--output', 'json']);
|
||||
} catch (error) {
|
||||
throw new Error(`Solana confirmation failed for ${signature}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFulfillmentId(requestId, solanaSymbol, solanaMint) {
|
||||
return ethers.keccak256(
|
||||
ethers.solidityPacked(['bytes32', 'string', 'string'], [requestId, solanaSymbol, solanaMint])
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const once = process.argv.includes('--once');
|
||||
const runtimeBundle = loadRuntime();
|
||||
const runtime = runtimeBundle.config;
|
||||
const rpcUrl = process.env.RPC_URL_138 || process.env.CHAIN138_RPC_URL || runtime.sourceChain.rpcEnv;
|
||||
const privateKey = process.env.PRIVATE_KEY;
|
||||
if (!privateKey || privateKey.includes('${')) {
|
||||
throw new Error('PRIVATE_KEY is required');
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
const signer = new ethers.Wallet(privateKey, provider);
|
||||
const adapterAddress = normalizeAddress(runtime.sourceChain.adapterAddress);
|
||||
const adapterAbi = [
|
||||
'function confirmTransaction(bytes32 requestId,string txSignature,uint256 finalizedSlot,bytes32 fulfillmentId)',
|
||||
'function getBridgeStatus(bytes32 requestId) view returns (tuple(address sender,address token,uint256 amount,bytes destinationData,bytes32 requestId,uint8 status,uint256 createdAt,uint256 completedAt))',
|
||||
'event SolanaBridgeInitiated(bytes32 indexed requestId,address indexed sender,address indexed token,uint256 amount,string destination,bytes recipient)'
|
||||
];
|
||||
const adapter = new ethers.Contract(adapterAddress, adapterAbi, signer);
|
||||
const interfaceDecoder = adapter.interface;
|
||||
const assetBySourceToken = new Map(
|
||||
(runtime.assets || []).map((asset) => [normalizeAddress(asset.sourceToken), asset])
|
||||
);
|
||||
const state = readJson(defaultStatePath, { lastBlock: 0, processed: {} });
|
||||
const latestBlock = await provider.getBlockNumber();
|
||||
const fromBlock = Math.max(0, Number(state.lastBlock || 0) + 1);
|
||||
const health = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtimeConfig: runtimeBundle.path,
|
||||
adapterAddress,
|
||||
signer: signer.address,
|
||||
fromBlock,
|
||||
latestBlock,
|
||||
processedThisRun: [],
|
||||
mode: once ? 'once' : 'poll'
|
||||
};
|
||||
|
||||
if (fromBlock > latestBlock) {
|
||||
state.lastBlock = latestBlock;
|
||||
writeJson(defaultStatePath, state);
|
||||
writeJson(defaultHealthPath, health);
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = await provider.getLogs({
|
||||
address: adapterAddress,
|
||||
fromBlock,
|
||||
toBlock: latestBlock,
|
||||
topics: [ethers.id('SolanaBridgeInitiated(bytes32,address,address,uint256,string,bytes)')]
|
||||
});
|
||||
|
||||
for (const log of logs) {
|
||||
const parsed = interfaceDecoder.parseLog(log);
|
||||
const requestId = parsed.args.requestId;
|
||||
if (state.processed[requestId]) continue;
|
||||
|
||||
const request = await adapter.getBridgeStatus(requestId);
|
||||
if (Number(request.status) !== 1) continue;
|
||||
|
||||
const sourceToken = normalizeAddress(parsed.args.token);
|
||||
const asset = assetBySourceToken.get(sourceToken);
|
||||
if (!asset) {
|
||||
throw new Error(`No Solana runtime asset for source token ${sourceToken}`);
|
||||
}
|
||||
|
||||
const recipientWallet = decodeRecipient(parsed.args.destination, parsed.args.recipient);
|
||||
if (!recipientWallet) {
|
||||
throw new Error(`Missing Solana recipient for request ${requestId}`);
|
||||
}
|
||||
|
||||
const uiAmount = rawToUiAmount(request.amount.toString(), asset.decimals);
|
||||
const solanaSignature = mintToDestination(
|
||||
asset.solanaMint,
|
||||
uiAmount,
|
||||
recipientWallet,
|
||||
runtime.solana.keypairPath
|
||||
);
|
||||
confirmSolanaSignature(solanaSignature);
|
||||
|
||||
const fulfillmentId = buildFulfillmentId(requestId, asset.solanaSymbol, asset.solanaMint);
|
||||
const confirmTx = await adapter.confirmTransaction(
|
||||
requestId,
|
||||
solanaSignature,
|
||||
BigInt(runtime.solana.confirmationFinality),
|
||||
fulfillmentId,
|
||||
{
|
||||
type: 0,
|
||||
gasPrice: BigInt(process.env.CHAIN138_TX_GAS_PRICE || '200000')
|
||||
}
|
||||
);
|
||||
const confirmReceipt = await confirmTx.wait();
|
||||
|
||||
state.processed[requestId] = {
|
||||
blockNumber: log.blockNumber,
|
||||
sourceToken,
|
||||
solanaSymbol: asset.solanaSymbol,
|
||||
solanaMint: asset.solanaMint,
|
||||
recipientWallet,
|
||||
requestAmountRaw: request.amount.toString(),
|
||||
solanaSignature,
|
||||
confirmTxHash: confirmReceipt.hash,
|
||||
processedAt: new Date().toISOString()
|
||||
};
|
||||
health.processedThisRun.push({
|
||||
requestId,
|
||||
solanaSymbol: asset.solanaSymbol,
|
||||
solanaSignature,
|
||||
confirmTxHash: confirmReceipt.hash
|
||||
});
|
||||
}
|
||||
|
||||
state.lastBlock = latestBlock;
|
||||
writeJson(defaultStatePath, state);
|
||||
writeJson(defaultHealthPath, health);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
writeJson(defaultHealthPath, {
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: 'error',
|
||||
error: error.message
|
||||
});
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user