feat: expand non-evm relay and route planning support

This commit is contained in:
defiQUG
2026-04-18 12:05:34 -07:00
parent da78073104
commit 843cdbf71c
113 changed files with 8542 additions and 222 deletions

View 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

View 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"
}
}

View 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
};
}
}

View 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);
});