feat: expand non-evm relay and route planning support
This commit is contained in:
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