240 lines
7.9 KiB
JavaScript
240 lines
7.9 KiB
JavaScript
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);
|
|
});
|