docs: Ledger Live integration, contract deploy learnings, NEXT_STEPS updates
Some checks failed
Deploy to Phoenix / deploy (push) Has been cancelled
Some checks failed
Deploy to Phoenix / deploy (push) Has been cancelled
- ADD_CHAIN138_TO_LEDGER_LIVE: Ledger form done; public code review repo bis-innovations/LedgerLive; init/push commands - CONTRACT_DEPLOYMENT_RUNBOOK: Chain 138 gas price 1 gwei, 36-addr check, TransactionMirror workaround - CONTRACT_*: AddressMapper, MirrorManager deployed 2026-02-12; 36-address on-chain check - NEXT_STEPS_FOR_YOU: Ledger done; steps completable now (no LAN); run-completable-tasks-from-anywhere - MASTER_INDEX, OPERATOR_OPTIONAL, SMART_CONTRACTS_INVENTORY_SIMPLE: updates - LEDGER_BLOCKCHAIN_INTEGRATION_COMPLETE: bis-innovations/LedgerLive reference Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
105
multi-chain-execution/src/api/admin-routes.ts
Normal file
105
multi-chain-execution/src/api/admin-routes.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Admin/ops API (protected). Policies, key rotation, circuit-breaker.
|
||||
* Auth: ADMIN_API_KEY (x-admin-key or admin_key query) or JWT later.
|
||||
* Audit: sends to dbis_core central audit when DBIS_CENTRAL_URL + ADMIN_CENTRAL_API_KEY set.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { setCircuitBreaker } from './observability.js';
|
||||
import { appendCentralAudit } from './central-audit.js';
|
||||
|
||||
const router: Router = Router();
|
||||
const ADMIN_API_KEY = process.env.ADMIN_API_KEY;
|
||||
|
||||
let policies: Record<string, unknown> = {};
|
||||
let lastKeyRotationAt: Date | null = null;
|
||||
|
||||
function getAdminSubject(req: Request): string {
|
||||
return (req.headers['x-admin-subject'] as string) || 'multi-chain-execution';
|
||||
}
|
||||
|
||||
function adminAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!ADMIN_API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const key = req.headers['x-admin-key'] ?? req.query.admin_key;
|
||||
if (key !== ADMIN_API_KEY) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.use(adminAuth);
|
||||
|
||||
router.post('/v1/admin/policies', (req: Request, res: Response) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
if (body && typeof body === 'object') {
|
||||
policies = { ...policies, ...body };
|
||||
}
|
||||
appendCentralAudit({
|
||||
employeeId: getAdminSubject(req),
|
||||
action: 'update_policies',
|
||||
permission: 'admin:action',
|
||||
resourceType: 'policies',
|
||||
metadata: body,
|
||||
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0],
|
||||
userAgent: req.get('user-agent') ?? undefined,
|
||||
}).catch(() => {});
|
||||
res.status(200).json({ message: 'Policy update accepted', policies });
|
||||
});
|
||||
|
||||
router.get('/v1/admin/policies', (_req: Request, res: Response) => {
|
||||
res.status(200).json({ policies });
|
||||
});
|
||||
|
||||
router.post('/v1/admin/keys/rotate', (req: Request, res: Response) => {
|
||||
lastKeyRotationAt = new Date();
|
||||
appendCentralAudit({
|
||||
employeeId: getAdminSubject(req),
|
||||
action: 'keys_rotate',
|
||||
permission: 'admin:action',
|
||||
resourceType: 'keys',
|
||||
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0],
|
||||
userAgent: req.get('user-agent') ?? undefined,
|
||||
}).catch(() => {});
|
||||
res.status(200).json({
|
||||
message: 'Key rotation initiated',
|
||||
rotated_at: lastKeyRotationAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/v1/admin/keys/status', (_req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
last_rotation: lastKeyRotationAt?.toISOString() ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/v1/admin/circuit-breaker/on', (req: Request, res: Response) => {
|
||||
setCircuitBreaker(true);
|
||||
appendCentralAudit({
|
||||
employeeId: getAdminSubject(req),
|
||||
action: 'circuit_breaker_on',
|
||||
permission: 'admin:action',
|
||||
resourceType: 'circuit_breaker',
|
||||
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0],
|
||||
userAgent: req.get('user-agent') ?? undefined,
|
||||
}).catch(() => {});
|
||||
res.status(200).json({ message: 'Circuit breaker forced open' });
|
||||
});
|
||||
|
||||
router.post('/v1/admin/circuit-breaker/off', (req: Request, res: Response) => {
|
||||
setCircuitBreaker(false);
|
||||
appendCentralAudit({
|
||||
employeeId: getAdminSubject(req),
|
||||
action: 'circuit_breaker_off',
|
||||
permission: 'admin:action',
|
||||
resourceType: 'circuit_breaker',
|
||||
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0],
|
||||
userAgent: req.get('user-agent') ?? undefined,
|
||||
}).catch(() => {});
|
||||
res.status(200).json({ message: 'Circuit breaker forced closed' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
56
multi-chain-execution/src/api/central-audit.ts
Normal file
56
multi-chain-execution/src/api/central-audit.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Central audit client for multi-chain-execution admin actions.
|
||||
* Sends audit entries to dbis_core Admin Central API when DBIS_CENTRAL_URL and ADMIN_CENTRAL_API_KEY are set.
|
||||
*/
|
||||
|
||||
const DBIS_CENTRAL_URL = process.env.DBIS_CENTRAL_URL?.replace(/\/$/, '');
|
||||
const ADMIN_CENTRAL_API_KEY = process.env.ADMIN_CENTRAL_API_KEY;
|
||||
const SERVICE_NAME = 'multi_chain_execution';
|
||||
|
||||
function isConfigured(): boolean {
|
||||
return Boolean(DBIS_CENTRAL_URL && ADMIN_CENTRAL_API_KEY);
|
||||
}
|
||||
|
||||
export interface CentralAuditPayload {
|
||||
employeeId: string;
|
||||
action: string;
|
||||
permission: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
outcome?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export async function appendCentralAudit(payload: CentralAuditPayload): Promise<void> {
|
||||
if (!isConfigured()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${DBIS_CENTRAL_URL}/api/admin/central/audit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Central-Key': ADMIN_CENTRAL_API_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employeeId: payload.employeeId,
|
||||
action: payload.action,
|
||||
permission: payload.permission ?? 'admin:action',
|
||||
resourceType: payload.resourceType,
|
||||
resourceId: payload.resourceId,
|
||||
project: 'multi-chain-execution',
|
||||
service: SERVICE_NAME,
|
||||
outcome: payload.outcome ?? 'success',
|
||||
metadata: payload.metadata,
|
||||
ipAddress: payload.ipAddress,
|
||||
userAgent: payload.userAgent,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[central-audit] POST failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[central-audit] append failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
47
multi-chain-execution/src/api/execution-routes.ts
Normal file
47
multi-chain-execution/src/api/execution-routes.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Execution read API: GET /v1/executions/:id, GET /v1/tx/:chain_id/:tx_hash, GET /v1/audit/:executionId
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getExecution } from '../eo/execution-orchestrator.js';
|
||||
import { getAdapter } from '../chain-adapters/get-adapter.js';
|
||||
import { getAudit } from '../audit/audit-store.js';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.get('/v1/executions/:executionId', (req: Request, res: Response) => {
|
||||
const exec = getExecution(req.params.executionId);
|
||||
if (!exec) return res.status(404).json({ error: 'Execution not found' });
|
||||
res.json(exec);
|
||||
});
|
||||
|
||||
router.get('/v1/audit/:executionId', (req: Request, res: Response) => {
|
||||
const audit = getAudit(req.params.executionId);
|
||||
if (!audit) return res.status(404).json({ error: 'Audit not found' });
|
||||
res.json(audit);
|
||||
});
|
||||
|
||||
router.get('/v1/tx/:chainId/:txHash', async (req: Request, res: Response) => {
|
||||
const chainId = parseInt(req.params.chainId, 10);
|
||||
const txHash = req.params.txHash;
|
||||
if (isNaN(chainId) || !txHash) {
|
||||
return res.status(400).json({ error: 'Invalid chain_id or tx_hash' });
|
||||
}
|
||||
try {
|
||||
const adapter = getAdapter(chainId);
|
||||
const receipt = await adapter.getTransactionReceipt(txHash);
|
||||
if (!receipt) return res.status(404).json({ error: 'Transaction not found or pending' });
|
||||
const logs = await adapter.getLogs(
|
||||
Number(receipt.blockNumber),
|
||||
Number(receipt.blockNumber),
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
const txLogs = logs.filter((l) => l.transactionHash.toLowerCase() === txHash.toLowerCase());
|
||||
res.json({ receipt, logs: txLogs });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : 'RPC error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
40
multi-chain-execution/src/api/intent-routes.ts
Normal file
40
multi-chain-execution/src/api/intent-routes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { createIntent, getIntent, executeIntent } from '../eo/execution-orchestrator.js';
|
||||
import type { IntentRequest } from '../intent/types.js';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.post('/v1/intents', (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as IntentRequest;
|
||||
const intent = createIntent(body);
|
||||
res.status(201).json({
|
||||
intent_id: intent.intent_id,
|
||||
status: intent.status,
|
||||
planned_steps: intent.planned_steps,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e instanceof Error ? e.message : 'Bad request' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/v1/intents/:intentId/execute', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { intentId } = req.params;
|
||||
const execution = await executeIntent(intentId);
|
||||
res.status(202).json({
|
||||
execution_id: execution.execution_id,
|
||||
submitted_txs: execution.submitted_txs,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e instanceof Error ? e.message : 'Execute failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/v1/intents/:intentId', (req: Request, res: Response) => {
|
||||
const intent = getIntent(req.params.intentId);
|
||||
if (!intent) return res.status(404).json({ error: 'Intent not found' });
|
||||
res.json(intent);
|
||||
});
|
||||
|
||||
export default router;
|
||||
83
multi-chain-execution/src/api/mirror-routes.ts
Normal file
83
multi-chain-execution/src/api/mirror-routes.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Mirror API: POST /v1/mirror/commit, GET /v1/mirror/commits/:id
|
||||
* Proof API: GET /v1/mirror/proof?chain_id=&tx_hash=
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { buildCommitment, type CommitmentLeaf } from '../mirroring/merkle-commitment.js';
|
||||
import { saveCommit, getCommit, getProof } from '../mirroring/mirror-store.js';
|
||||
import type { StoredCommit } from '../mirroring/mirror-store.js';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.post('/v1/mirror/commit', (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as { chain_id: number; leaves: CommitmentLeaf[]; uri?: string };
|
||||
const { chain_id, leaves, uri = '' } = body;
|
||||
if (!leaves?.length || chain_id == null) {
|
||||
return res.status(400).json({ error: 'chain_id and leaves required' });
|
||||
}
|
||||
const result = buildCommitment(leaves, chain_id);
|
||||
const commitId = `commit-${uuidv4()}`;
|
||||
const leavesByTxHash = new Map<string, { leafIndex: number; leafData: unknown }>();
|
||||
leaves.forEach((leaf, i) => {
|
||||
leavesByTxHash.set(leaf.txHash.toLowerCase(), { leafIndex: i, leafData: leaf });
|
||||
});
|
||||
const stored: StoredCommit = {
|
||||
commitId,
|
||||
chainId: chain_id,
|
||||
startBlock: result.startBlock,
|
||||
endBlock: result.endBlock,
|
||||
root: result.root,
|
||||
uri,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
leafHashes: result.leafHashes,
|
||||
leavesByTxHash,
|
||||
publicChainTxHashes: [], // filled when MS posts to MirrorRegistry
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveCommit(stored);
|
||||
res.status(201).json({
|
||||
commit_id: commitId,
|
||||
root: result.root,
|
||||
start_block: result.startBlock,
|
||||
end_block: result.endBlock,
|
||||
chain_id: result.chainId,
|
||||
schema_version: result.schemaVersion,
|
||||
leaf_count: result.leafCount,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e instanceof Error ? e.message : 'Bad request' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/v1/mirror/commits/:commitId', (req: Request, res: Response) => {
|
||||
const c = getCommit(req.params.commitId);
|
||||
if (!c) return res.status(404).json({ error: 'Commit not found' });
|
||||
res.json({
|
||||
commit_id: c.commitId,
|
||||
chain_id: c.chainId,
|
||||
start_block: c.startBlock,
|
||||
end_block: c.endBlock,
|
||||
root: c.root,
|
||||
uri: c.uri,
|
||||
timestamp: c.timestamp,
|
||||
leaf_count: c.leafHashes.length,
|
||||
public_chain_tx_hashes: c.publicChainTxHashes,
|
||||
created_at: c.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/v1/mirror/proof', (req: Request, res: Response) => {
|
||||
const chainId = parseInt(req.query.chain_id as string, 10);
|
||||
const txHash = req.query.tx_hash as string;
|
||||
if (isNaN(chainId) || !txHash) {
|
||||
return res.status(400).json({ error: 'chain_id and tx_hash query params required' });
|
||||
}
|
||||
const proof = getProof(chainId, txHash);
|
||||
if (!proof) return res.status(404).json({ error: 'No proof found for this tx' });
|
||||
res.json(proof);
|
||||
});
|
||||
|
||||
export default router;
|
||||
61
multi-chain-execution/src/api/observability.ts
Normal file
61
multi-chain-execution/src/api/observability.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Observability: circuit breaker, health, metrics.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
|
||||
let circuitOpen = false;
|
||||
let errorCount = 0;
|
||||
let lastErrorAt = 0;
|
||||
const ERROR_THRESHOLD = 10;
|
||||
const RESET_MS = 60_000;
|
||||
|
||||
/** Admin: force circuit breaker on or off. */
|
||||
export function setCircuitBreaker(open: boolean): void {
|
||||
circuitOpen = open;
|
||||
if (!open) errorCount = 0;
|
||||
}
|
||||
|
||||
export function circuitBreakerMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (circuitOpen) {
|
||||
if (Date.now() - lastErrorAt > RESET_MS) {
|
||||
circuitOpen = false;
|
||||
errorCount = 0;
|
||||
} else {
|
||||
res.status(503).json({ error: 'Circuit breaker open', retry_after: RESET_MS / 1000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode >= 500) {
|
||||
errorCount++;
|
||||
lastErrorAt = Date.now();
|
||||
if (errorCount >= ERROR_THRESHOLD) circuitOpen = true;
|
||||
} else {
|
||||
errorCount = Math.max(0, errorCount - 1);
|
||||
}
|
||||
});
|
||||
next();
|
||||
}
|
||||
|
||||
const healthRouter: Router = Router();
|
||||
healthRouter.get('/v1/health', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: circuitOpen ? 'degraded' : 'ok',
|
||||
circuit_breaker: circuitOpen ? 'open' : 'closed',
|
||||
error_count: errorCount,
|
||||
});
|
||||
});
|
||||
healthRouter.get('/v1/metrics', (_req: Request, res: Response) => {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.send(
|
||||
`# HELP multi_chain_execution_errors Total 5xx errors\n` +
|
||||
`# TYPE multi_chain_execution_errors counter\n` +
|
||||
`multi_chain_execution_errors ${errorCount}\n` +
|
||||
`# HELP multi_chain_execution_circuit_open Circuit breaker open (1=open)\n` +
|
||||
`# TYPE multi_chain_execution_circuit_open gauge\n` +
|
||||
`multi_chain_execution_circuit_open ${circuitOpen ? 1 : 0}\n`
|
||||
);
|
||||
});
|
||||
|
||||
export const healthRoutes = healthRouter;
|
||||
89
multi-chain-execution/src/api/route-routes.ts
Normal file
89
multi-chain-execution/src/api/route-routes.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Chain138 -> Tezos USDtz route planning API
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
|
||||
const TEZOS_REGEX = /^(tz[1-4]|KT1)[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_ALL_MAINNET = 651940;
|
||||
const CUSDC = '0xf22258f57794CC8E06237084b353Ab30fFfa640b';
|
||||
const AUSDC = '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881';
|
||||
/** Active ETH→Tezos bridge (from bridge-capability-matrix) */
|
||||
const ETH_TO_TEZOS_PROVIDER = 'Wrap Protocol';
|
||||
|
||||
interface RoutePlanRequest {
|
||||
source_chain_id?: number;
|
||||
source_asset?: string;
|
||||
source_amount?: string;
|
||||
destination_tezos_address?: string;
|
||||
max_slippage_bps?: number;
|
||||
max_total_fees?: string;
|
||||
prefer_non_custodial?: boolean;
|
||||
}
|
||||
|
||||
interface RouteHop {
|
||||
chain: string;
|
||||
action: string;
|
||||
protocol: string;
|
||||
asset_in: string;
|
||||
amount_in: string;
|
||||
asset_out: string;
|
||||
min_amount_out: string;
|
||||
estimated_fees: string;
|
||||
}
|
||||
|
||||
interface RoutePlan {
|
||||
route_id: string;
|
||||
hops: RouteHop[];
|
||||
totalEstimatedFees: string;
|
||||
estimatedTimeSeconds: number;
|
||||
}
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.post('/v1/routes/chain138-to-usdtz', (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as RoutePlanRequest;
|
||||
const sourceChainId = body.source_chain_id ?? 138;
|
||||
const sourceAsset = body.source_asset ?? CUSDC;
|
||||
const sourceAmount = body.source_amount ?? '0';
|
||||
const destAddr = body.destination_tezos_address ?? '';
|
||||
|
||||
if (sourceChainId !== CHAIN_138 && sourceChainId !== CHAIN_ALL_MAINNET) {
|
||||
return res.status(400).json({ valid: false, error: 'Only source_chain_id=138 (Chain138) or 651940 (ALL Mainnet) is supported' });
|
||||
}
|
||||
if (!destAddr.trim() || !TEZOS_REGEX.test(destAddr.trim())) {
|
||||
return res.status(400).json({ valid: false, error: 'Invalid destination_tezos_address' });
|
||||
}
|
||||
const amount = BigInt(sourceAmount);
|
||||
if (amount <= 0n) {
|
||||
return res.status(400).json({ valid: false, error: 'source_amount must be > 0' });
|
||||
}
|
||||
|
||||
const routeId = `route-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const isChain138 = sourceChainId === CHAIN_138;
|
||||
const sourceLabel = isChain138 ? 'CHAIN138' : 'ALL_MAINNET';
|
||||
const stableOut = isChain138 ? CUSDC : AUSDC;
|
||||
const srcBridge = isChain138 ? 'CCIP' : 'AlltraAdapter';
|
||||
const hops: RouteHop[] = [
|
||||
{ chain: sourceLabel, action: 'SWAP', protocol: isChain138 ? 'EnhancedSwapRouter' : 'AlltraDEX', asset_in: sourceAsset, amount_in: sourceAmount, asset_out: stableOut, min_amount_out: sourceAmount, estimated_fees: '0' },
|
||||
{ chain: sourceLabel, action: 'BRIDGE', protocol: srcBridge, asset_in: stableOut, amount_in: sourceAmount, asset_out: 'USDC', min_amount_out: sourceAmount, estimated_fees: '0' },
|
||||
{ chain: 'HUB_EVM', action: 'BRIDGE', protocol: ETH_TO_TEZOS_PROVIDER, asset_in: 'USDC', amount_in: sourceAmount, asset_out: 'USDC', min_amount_out: sourceAmount, estimated_fees: '0' },
|
||||
{ chain: 'TEZOS', action: 'SWAP', protocol: 'Plenty', asset_in: 'USDC', amount_in: sourceAmount, asset_out: 'USDtz', min_amount_out: sourceAmount, estimated_fees: '0' },
|
||||
];
|
||||
|
||||
const plan: RoutePlan = {
|
||||
route_id: routeId,
|
||||
hops,
|
||||
totalEstimatedFees: '0',
|
||||
estimatedTimeSeconds: 1800,
|
||||
};
|
||||
|
||||
res.status(200).json({ valid: true, routes: [plan] });
|
||||
} catch (e) {
|
||||
res.status(500).json({ valid: false, error: e instanceof Error ? e.message : 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
24
multi-chain-execution/src/api/server.ts
Normal file
24
multi-chain-execution/src/api/server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import intentRoutes from './intent-routes.js';
|
||||
import executionRoutes from './execution-routes.js';
|
||||
import mirrorRoutes from './mirror-routes.js';
|
||||
import adminRoutes from './admin-routes.js';
|
||||
import routeRoutes from './route-routes.js';
|
||||
import { circuitBreakerMiddleware, healthRoutes } from './observability.js';
|
||||
|
||||
const app: express.Express = express();
|
||||
app.use(express.json());
|
||||
app.use(circuitBreakerMiddleware);
|
||||
app.use(intentRoutes);
|
||||
app.use(executionRoutes);
|
||||
app.use(routeRoutes);
|
||||
app.use(mirrorRoutes);
|
||||
app.use(adminRoutes);
|
||||
app.use(healthRoutes);
|
||||
|
||||
const port = parseInt(process.env.PORT ?? '3001', 10);
|
||||
app.listen(port, () => {
|
||||
console.log(`Multi-chain execution API listening on port ${port}`);
|
||||
});
|
||||
|
||||
export { app };
|
||||
47
multi-chain-execution/src/audit/audit-store.ts
Normal file
47
multi-chain-execution/src/audit/audit-store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Audit store for Chain138->Tezos USDtz executions.
|
||||
* Stores pre-trade quotes, tx hashes per hop, bridge message IDs, final delivered amount.
|
||||
*/
|
||||
|
||||
export interface ExecutionAudit {
|
||||
intent_id: string;
|
||||
execution_id: string;
|
||||
route_id?: string;
|
||||
created_at: string;
|
||||
status: 'planned' | 'quoted' | 'executing' | 'completed' | 'failed';
|
||||
pre_trade_quote?: {
|
||||
amount_in: string;
|
||||
expected_out: string;
|
||||
fees: string;
|
||||
quoted_at: string;
|
||||
};
|
||||
hops: Array<{
|
||||
step_index: number;
|
||||
chain_id: number;
|
||||
action: string;
|
||||
tx_hash?: string;
|
||||
block_number?: string;
|
||||
bridge_message_id?: string;
|
||||
status: string;
|
||||
}>;
|
||||
final_delivered?: {
|
||||
amount: string;
|
||||
asset: string;
|
||||
destination: string;
|
||||
tx_hash?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const store = new Map<string, ExecutionAudit>();
|
||||
|
||||
export function recordAudit(audit: ExecutionAudit): void {
|
||||
store.set(audit.execution_id, audit);
|
||||
}
|
||||
|
||||
export function getAudit(executionId: string): ExecutionAudit | undefined {
|
||||
return store.get(executionId);
|
||||
}
|
||||
|
||||
export function getAuditsByIntent(intentId: string): ExecutionAudit[] {
|
||||
return Array.from(store.values()).filter((a) => a.intent_id === intentId);
|
||||
}
|
||||
17
multi-chain-execution/src/chain-adapters/adapter-138.ts
Normal file
17
multi-chain-execution/src/chain-adapters/adapter-138.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { BaseChainAdapter } from './base-adapter.js';
|
||||
import { getChainConfig } from './config.js';
|
||||
|
||||
/**
|
||||
* Chain adapter for DBIS Chain ID 138.
|
||||
*/
|
||||
export class ChainAdapter138 extends BaseChainAdapter {
|
||||
constructor(rpcUrls?: string[]) {
|
||||
const config = getChainConfig(138);
|
||||
if (!config) throw new Error('Chain 138 config not found');
|
||||
super(138, rpcUrls ?? config.rpcUrls);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdapter138(rpcUrls?: string[]): ChainAdapter138 {
|
||||
return new ChainAdapter138(rpcUrls);
|
||||
}
|
||||
17
multi-chain-execution/src/chain-adapters/adapter-651940.ts
Normal file
17
multi-chain-execution/src/chain-adapters/adapter-651940.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { BaseChainAdapter } from './base-adapter.js';
|
||||
import { getChainConfig } from './config.js';
|
||||
|
||||
/**
|
||||
* Chain adapter for ALL Mainnet (651940).
|
||||
*/
|
||||
export class ChainAdapter651940 extends BaseChainAdapter {
|
||||
constructor(rpcUrls?: string[]) {
|
||||
const config = getChainConfig(651940);
|
||||
if (!config) throw new Error('Chain 651940 config not found');
|
||||
super(651940, rpcUrls ?? config.rpcUrls);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdapter651940(rpcUrls?: string[]): ChainAdapter651940 {
|
||||
return new ChainAdapter651940(rpcUrls);
|
||||
}
|
||||
24
multi-chain-execution/src/chain-adapters/adapter-public.ts
Normal file
24
multi-chain-execution/src/chain-adapters/adapter-public.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { BaseChainAdapter } from './base-adapter.js';
|
||||
import { getChainConfig, getSupportedChainIds } from './config.js';
|
||||
|
||||
/**
|
||||
* Generic public mainnet adapter (Ethereum, Arbitrum, Base, Polygon, BSC).
|
||||
*/
|
||||
export class ChainAdapterPublic extends BaseChainAdapter {
|
||||
constructor(chainId: number, rpcUrls?: string[]) {
|
||||
const config = getChainConfig(chainId);
|
||||
if (!config) throw new Error(`Unsupported public chainId: ${chainId}`);
|
||||
if (chainId === 138 || chainId === 651940) {
|
||||
throw new Error('Use ChainAdapter138 or ChainAdapter651940 for private chains');
|
||||
}
|
||||
super(chainId, rpcUrls ?? config.rpcUrls);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdapterPublic(chainId: number, rpcUrls?: string[]): ChainAdapterPublic {
|
||||
return new ChainAdapterPublic(chainId, rpcUrls);
|
||||
}
|
||||
|
||||
export function getPublicChainIds(): number[] {
|
||||
return getSupportedChainIds().filter((id) => id !== 138 && id !== 651940);
|
||||
}
|
||||
140
multi-chain-execution/src/chain-adapters/adapter-tezos.ts
Normal file
140
multi-chain-execution/src/chain-adapters/adapter-tezos.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Chain adapter for Tezos mainnet (chainId 1729).
|
||||
* Uses TzKT API for read operations. sendTransaction requires Taquito/injection for production.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IChainAdapter,
|
||||
ChainAdapterConfig,
|
||||
NormalizedReceipt,
|
||||
NormalizedLog,
|
||||
SendTransactionResult,
|
||||
} from './types.js';
|
||||
import { getChainConfig, TEZOS_CHAIN_ID } from './config.js';
|
||||
|
||||
const TZKt_BASE = 'https://api.tzkt.io';
|
||||
|
||||
export class TezosChainAdapter implements IChainAdapter {
|
||||
private config: ChainAdapterConfig;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(rpcUrls?: string[]) {
|
||||
const cfg = getChainConfig(TEZOS_CHAIN_ID);
|
||||
if (!cfg) throw new Error('Tezos chain config not found');
|
||||
this.config = rpcUrls?.length ? { ...cfg, rpcUrls } : cfg;
|
||||
this.baseUrl = this.config.rpcUrls[0].replace(/\/$/, '');
|
||||
}
|
||||
|
||||
getChainId(): number {
|
||||
return this.config.chainId;
|
||||
}
|
||||
|
||||
getConfig(): ChainAdapterConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
async getBlockNumber(): Promise<number> {
|
||||
const res = await fetch(`${this.baseUrl}/v1/blocks/count`);
|
||||
if (!res.ok) throw new Error(`TzKT blocks/count failed: ${res.status}`);
|
||||
const count = await res.json();
|
||||
return Number(count);
|
||||
}
|
||||
|
||||
async getBlock(blockNumber: number): Promise<{ number: number; hash: string; parentHash: string; timestamp: number } | null> {
|
||||
const res = await fetch(`${this.baseUrl}/v1/blocks/${blockNumber}`);
|
||||
if (!res.ok) return null;
|
||||
const b = await res.json();
|
||||
return {
|
||||
number: b.level,
|
||||
hash: b.hash ?? '',
|
||||
parentHash: b.previousHash ?? '',
|
||||
timestamp: new Date(b.timestamp).getTime() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async sendTransaction(signedTxHex: string): Promise<SendTransactionResult> {
|
||||
const hex = signedTxHex.startsWith('0x') ? signedTxHex.slice(2) : signedTxHex;
|
||||
const bytes = Buffer.from(hex, 'hex');
|
||||
const rpcUrl = process.env.TEZOS_RPC_INJECT_URL ?? 'https://mainnet.api.tez.ie';
|
||||
const res = await fetch(`${rpcUrl}/injection/operation`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: bytes,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Tezos injection failed: ${res.status} ${err}`);
|
||||
}
|
||||
const opHash = await res.text();
|
||||
return { hash: opHash.trim(), from: '', nonce: 0 };
|
||||
}
|
||||
|
||||
async getTransactionReceipt(txHash: string): Promise<NormalizedReceipt | null> {
|
||||
const res = await fetch(`${this.baseUrl}/v1/operations/transactions/${txHash}`);
|
||||
if (!res.ok) return null;
|
||||
const op = await res.json();
|
||||
if (Array.isArray(op)) {
|
||||
const t = op[0];
|
||||
if (!t) return null;
|
||||
return {
|
||||
chainId: this.config.chainId,
|
||||
transactionHash: t.hash,
|
||||
blockNumber: BigInt(t.level ?? 0),
|
||||
blockHash: t.block ?? '',
|
||||
transactionIndex: 0,
|
||||
from: t.sender?.address ?? '',
|
||||
to: t.target?.address ?? null,
|
||||
gasUsed: BigInt(t.gasUsed ?? 0),
|
||||
cumulativeGasUsed: BigInt(t.gasUsed ?? 0),
|
||||
contractAddress: t.target?.address ?? null,
|
||||
logsBloom: '',
|
||||
status: t.status === 'applied' ? 1 : 0,
|
||||
root: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
chainId: this.config.chainId,
|
||||
transactionHash: op.hash,
|
||||
blockNumber: BigInt(op.level ?? 0),
|
||||
blockHash: op.block ?? '',
|
||||
transactionIndex: 0,
|
||||
from: op.sender?.address ?? '',
|
||||
to: op.target?.address ?? null,
|
||||
gasUsed: BigInt(op.gasUsed ?? 0),
|
||||
cumulativeGasUsed: BigInt(op.gasUsed ?? 0),
|
||||
contractAddress: op.target?.address ?? null,
|
||||
logsBloom: '',
|
||||
status: op.status === 'applied' ? 1 : 0,
|
||||
root: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getLogs(
|
||||
_fromBlock: number,
|
||||
_toBlock: number,
|
||||
_address?: string,
|
||||
_topics?: string[]
|
||||
): Promise<NormalizedLog[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async detectReorg(blockNumber: number, expectedBlockHash: string): Promise<boolean> {
|
||||
const block = await this.getBlock(blockNumber);
|
||||
if (!block) return true;
|
||||
return block.hash !== expectedBlockHash;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await fetch(`${this.baseUrl}/v1/blocks/head`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdapterTezos(rpcUrls?: string[]): TezosChainAdapter {
|
||||
return new TezosChainAdapter(rpcUrls);
|
||||
}
|
||||
156
multi-chain-execution/src/chain-adapters/base-adapter.ts
Normal file
156
multi-chain-execution/src/chain-adapters/base-adapter.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Base chain adapter: RPC abstraction, receipt/log fetch, reorg detection, fallback RPC.
|
||||
*/
|
||||
|
||||
import { JsonRpcProvider, TransactionReceipt, Log } from 'ethers';
|
||||
import type { ChainAdapterConfig, NormalizedReceipt, NormalizedLog, IChainAdapter } from './types.js';
|
||||
import { getChainConfig } from './config.js';
|
||||
|
||||
function toHex(n: bigint): string {
|
||||
return '0x' + n.toString(16);
|
||||
}
|
||||
|
||||
export abstract class BaseChainAdapter implements IChainAdapter {
|
||||
protected provider: JsonRpcProvider;
|
||||
protected config: ChainAdapterConfig;
|
||||
private rpcIndex = 0;
|
||||
|
||||
constructor(chainId: number, rpcUrls?: string[]) {
|
||||
const cfg = getChainConfig(chainId);
|
||||
if (!cfg) throw new Error(`Unknown chainId: ${chainId}`);
|
||||
this.config = rpcUrls?.length ? { ...cfg, rpcUrls } : cfg;
|
||||
this.provider = new JsonRpcProvider(this.config.rpcUrls[0]);
|
||||
}
|
||||
|
||||
getChainId(): number {
|
||||
return this.config.chainId;
|
||||
}
|
||||
|
||||
getConfig(): ChainAdapterConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
protected getRpcUrl(): string {
|
||||
return this.config.rpcUrls[this.rpcIndex % this.config.rpcUrls.length];
|
||||
}
|
||||
|
||||
protected async switchRpc(): Promise<void> {
|
||||
if (this.config.rpcUrls.length <= 1) return;
|
||||
this.rpcIndex++;
|
||||
this.provider = new JsonRpcProvider(this.getRpcUrl());
|
||||
}
|
||||
|
||||
async getBlockNumber(): Promise<number> {
|
||||
const n = await this.provider.getBlockNumber();
|
||||
return n;
|
||||
}
|
||||
|
||||
async getBlock(blockNumber: number): Promise<{ number: number; hash: string; parentHash: string; timestamp: number } | null> {
|
||||
try {
|
||||
const block = await this.provider.getBlock(blockNumber);
|
||||
if (!block) return null;
|
||||
return {
|
||||
number: block.number,
|
||||
hash: block.hash ?? '',
|
||||
parentHash: block.parentHash ?? '',
|
||||
timestamp: block.timestamp,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async sendTransaction(signedTxHex: string): Promise<{ hash: string; from: string; nonce: number }> {
|
||||
const tx = await this.provider.broadcastTransaction(signedTxHex);
|
||||
return {
|
||||
hash: tx.hash,
|
||||
from: tx.from ?? '',
|
||||
nonce: tx.nonce,
|
||||
};
|
||||
}
|
||||
|
||||
async getTransactionReceipt(txHash: string): Promise<NormalizedReceipt | null> {
|
||||
try {
|
||||
const receipt = await this.provider.getTransactionReceipt(txHash);
|
||||
if (!receipt) return null;
|
||||
return this.normalizeReceipt(receipt);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected normalizeReceipt(receipt: TransactionReceipt): NormalizedReceipt {
|
||||
return {
|
||||
chainId: this.config.chainId,
|
||||
transactionHash: receipt.hash,
|
||||
blockNumber: BigInt(receipt.blockNumber),
|
||||
blockHash: receipt.blockHash ?? '',
|
||||
transactionIndex: receipt.index,
|
||||
from: receipt.from,
|
||||
to: receipt.to ?? null,
|
||||
gasUsed: BigInt(receipt.gasUsed.toString()),
|
||||
cumulativeGasUsed: BigInt(receipt.cumulativeGasUsed.toString()),
|
||||
contractAddress: receipt.contractAddress ?? null,
|
||||
logsBloom: receipt.logsBloom ?? '',
|
||||
status: receipt.status === 1 ? 1 : 0,
|
||||
root: receipt.root ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getLogs(
|
||||
fromBlock: number,
|
||||
toBlock: number,
|
||||
address?: string,
|
||||
topics?: string[]
|
||||
): Promise<NormalizedLog[]> {
|
||||
const filter: { fromBlock: number; toBlock: number; address?: string; topics?: string[] } = {
|
||||
fromBlock,
|
||||
toBlock,
|
||||
};
|
||||
if (address) filter.address = address;
|
||||
if (topics?.length) filter.topics = topics as `0x${string}`[];
|
||||
const logs = await this.provider.getLogs(filter);
|
||||
return logs.map((log) => this.normalizeLog(log));
|
||||
}
|
||||
|
||||
protected normalizeLog(log: Log): NormalizedLog {
|
||||
const topics = log.topics as string[];
|
||||
return {
|
||||
chainId: this.config.chainId,
|
||||
transactionHash: log.transactionHash,
|
||||
blockNumber: BigInt(log.blockNumber),
|
||||
blockHash: log.blockHash ?? '',
|
||||
logIndex: log.index,
|
||||
address: log.address,
|
||||
topic0: topics[0] ?? null,
|
||||
topic1: topics[1] ?? null,
|
||||
topic2: topics[2] ?? null,
|
||||
topic3: topics[3] ?? null,
|
||||
data: log.data,
|
||||
};
|
||||
}
|
||||
|
||||
async detectReorg(blockNumber: number, expectedBlockHash: string): Promise<boolean> {
|
||||
const block = await this.getBlock(blockNumber);
|
||||
if (!block) return true;
|
||||
return block.hash.toLowerCase() !== expectedBlockHash.toLowerCase();
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.provider.getBlockNumber();
|
||||
return true;
|
||||
} catch {
|
||||
if (this.config.rpcUrls.length > 1) {
|
||||
await this.switchRpc();
|
||||
try {
|
||||
await this.provider.getBlockNumber();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
multi-chain-execution/src/chain-adapters/config.ts
Normal file
90
multi-chain-execution/src/chain-adapters/config.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Chain config for CA-138, CA-651940, and public mainnets.
|
||||
* Single source of truth for chainId, RPC, confirmations, reorg window.
|
||||
*/
|
||||
|
||||
import type { ChainAdapterConfig } from './types.js';
|
||||
|
||||
const CHAIN_138_RPC =
|
||||
process.env.CHAIN_138_RPC_URL ?? process.env.CHAIN138_RPC_URL ?? 'https://rpc-http-pub.d-bis.org';
|
||||
const CHAIN_651940_RPC =
|
||||
process.env.CHAIN_651940_RPC_URL ?? 'https://mainnet-rpc.alltra.global';
|
||||
const ETHEREUM_RPC = process.env.ETHEREUM_RPC_URL ?? 'https://eth.llamarpc.com';
|
||||
const ARBITRUM_RPC = process.env.ARBITRUM_RPC_URL ?? 'https://arb1.arbitrum.io/rpc';
|
||||
const BASE_RPC = process.env.BASE_RPC_URL ?? 'https://mainnet.base.org';
|
||||
const POLYGON_RPC = process.env.POLYGON_RPC_URL ?? 'https://polygon-rpc.com';
|
||||
const BSC_RPC = process.env.BSC_RPC_URL ?? 'https://bsc-dataseed.binance.org';
|
||||
|
||||
/** Tezos mainnet - chainId 1729 (Tezos founding year). Uses TzKT API for read operations. */
|
||||
const TEZOS_RPC =
|
||||
process.env.TEZOS_RPC_URL ?? process.env.TEZOS_TZKT_URL ?? 'https://api.tzkt.io';
|
||||
|
||||
/** Chain ID for Tezos mainnet (non-EVM) */
|
||||
export const TEZOS_CHAIN_ID = 1729;
|
||||
|
||||
export const CHAIN_ADAPTER_CONFIGS: Record<number, ChainAdapterConfig> = {
|
||||
138: {
|
||||
chainId: 138,
|
||||
rpcUrls: [CHAIN_138_RPC],
|
||||
confirmations: 20,
|
||||
chainKey: 'chainid-138',
|
||||
reorgWindowBlocks: 20,
|
||||
},
|
||||
651940: {
|
||||
chainId: 651940,
|
||||
rpcUrls: [CHAIN_651940_RPC],
|
||||
confirmations: 20,
|
||||
chainKey: 'all-mainnet',
|
||||
reorgWindowBlocks: 20,
|
||||
},
|
||||
1: {
|
||||
chainId: 1,
|
||||
rpcUrls: [ETHEREUM_RPC],
|
||||
confirmations: 32,
|
||||
chainKey: 'ethereum-mainnet',
|
||||
reorgWindowBlocks: 64,
|
||||
},
|
||||
42161: {
|
||||
chainId: 42161,
|
||||
rpcUrls: [ARBITRUM_RPC],
|
||||
confirmations: 20,
|
||||
chainKey: 'arbitrum-one',
|
||||
reorgWindowBlocks: 40,
|
||||
},
|
||||
8453: {
|
||||
chainId: 8453,
|
||||
rpcUrls: [BASE_RPC],
|
||||
confirmations: 10,
|
||||
chainKey: 'base',
|
||||
reorgWindowBlocks: 20,
|
||||
},
|
||||
137: {
|
||||
chainId: 137,
|
||||
rpcUrls: [POLYGON_RPC],
|
||||
confirmations: 128,
|
||||
chainKey: 'polygon',
|
||||
reorgWindowBlocks: 128,
|
||||
},
|
||||
56: {
|
||||
chainId: 56,
|
||||
rpcUrls: [BSC_RPC],
|
||||
confirmations: 15,
|
||||
chainKey: 'bsc',
|
||||
reorgWindowBlocks: 30,
|
||||
},
|
||||
[TEZOS_CHAIN_ID]: {
|
||||
chainId: TEZOS_CHAIN_ID,
|
||||
rpcUrls: [TEZOS_RPC],
|
||||
confirmations: 2,
|
||||
chainKey: 'tezos-mainnet',
|
||||
reorgWindowBlocks: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export function getChainConfig(chainId: number): ChainAdapterConfig | undefined {
|
||||
return CHAIN_ADAPTER_CONFIGS[chainId];
|
||||
}
|
||||
|
||||
export function getSupportedChainIds(): number[] {
|
||||
return Object.keys(CHAIN_ADAPTER_CONFIGS).map(Number);
|
||||
}
|
||||
13
multi-chain-execution/src/chain-adapters/get-adapter.ts
Normal file
13
multi-chain-execution/src/chain-adapters/get-adapter.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IChainAdapter } from './types.js';
|
||||
import { createAdapter138 } from './adapter-138.js';
|
||||
import { createAdapter651940 } from './adapter-651940.js';
|
||||
import { createAdapterPublic } from './adapter-public.js';
|
||||
import { createAdapterTezos } from './adapter-tezos.js';
|
||||
import { TEZOS_CHAIN_ID } from './config.js';
|
||||
|
||||
export function getAdapter(chainId: number): IChainAdapter {
|
||||
if (chainId === 138) return createAdapter138();
|
||||
if (chainId === 651940) return createAdapter651940();
|
||||
if (chainId === TEZOS_CHAIN_ID) return createAdapterTezos();
|
||||
return createAdapterPublic(chainId);
|
||||
}
|
||||
8
multi-chain-execution/src/chain-adapters/index.ts
Normal file
8
multi-chain-execution/src/chain-adapters/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './types.js';
|
||||
export * from './config.js';
|
||||
export * from './base-adapter.js';
|
||||
export * from './adapter-138.js';
|
||||
export * from './adapter-651940.js';
|
||||
export * from './adapter-public.js';
|
||||
export * from './adapter-tezos.js';
|
||||
export * from './get-adapter.js';
|
||||
78
multi-chain-execution/src/chain-adapters/types.ts
Normal file
78
multi-chain-execution/src/chain-adapters/types.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Chain adapter types for CA-138, CA-651940, CA-publicN.
|
||||
* Used by EO, EII, and Mirroring Service.
|
||||
*/
|
||||
|
||||
export interface ChainAdapterConfig {
|
||||
chainId: number;
|
||||
rpcUrls: string[];
|
||||
confirmations: number;
|
||||
chainKey: string;
|
||||
/** Block numbers below this are considered finalized (reorg window). */
|
||||
reorgWindowBlocks?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedReceipt {
|
||||
chainId: number;
|
||||
transactionHash: string;
|
||||
blockNumber: bigint;
|
||||
blockHash: string;
|
||||
transactionIndex: number;
|
||||
from: string;
|
||||
to: string | null;
|
||||
gasUsed: bigint;
|
||||
cumulativeGasUsed: bigint;
|
||||
contractAddress: string | null;
|
||||
logsBloom: string;
|
||||
status: number;
|
||||
root: string | null;
|
||||
}
|
||||
|
||||
export interface NormalizedLog {
|
||||
chainId: number;
|
||||
transactionHash: string;
|
||||
blockNumber: bigint;
|
||||
blockHash: string;
|
||||
logIndex: number;
|
||||
address: string;
|
||||
topic0: string | null;
|
||||
topic1: string | null;
|
||||
topic2: string | null;
|
||||
topic3: string | null;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface SendTransactionResult {
|
||||
hash: string;
|
||||
from: string;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
export interface IChainAdapter {
|
||||
getChainId(): number;
|
||||
getConfig(): ChainAdapterConfig;
|
||||
|
||||
/** Get current block number. */
|
||||
getBlockNumber(): Promise<number>;
|
||||
|
||||
/** Get block by number; returns null if not found. */
|
||||
getBlock(blockNumber: number): Promise<{ number: number; hash: string; parentHash: string; timestamp: number } | null>;
|
||||
|
||||
/** Send raw signed transaction (hex). Returns tx hash. */
|
||||
sendTransaction(signedTxHex: string): Promise<SendTransactionResult>;
|
||||
|
||||
/** Get transaction receipt; returns null if pending/unknown. */
|
||||
getTransactionReceipt(txHash: string): Promise<NormalizedReceipt | null>;
|
||||
|
||||
/** Get logs for block range (inclusive). */
|
||||
getLogs(fromBlock: number, toBlock: number, address?: string, topics?: string[]): Promise<NormalizedLog[]>;
|
||||
|
||||
/**
|
||||
* Reorg detection: fetch block by number and compare hash to expected.
|
||||
* Returns true if chain has reorged (hash mismatch).
|
||||
*/
|
||||
detectReorg(blockNumber: number, expectedBlockHash: string): Promise<boolean>;
|
||||
|
||||
/** Health check: can we reach RPC? */
|
||||
healthCheck(): Promise<boolean>;
|
||||
}
|
||||
138
multi-chain-execution/src/eo/execution-orchestrator.ts
Normal file
138
multi-chain-execution/src/eo/execution-orchestrator.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Execution Orchestrator (EO).
|
||||
* Consumes intents, allocates nonces, submits txs via chain adapters, stores intent_id -> step -> tx_hash.
|
||||
*/
|
||||
|
||||
import { validateAndPlan } from '../trpe/trpe.js';
|
||||
import type { IntentRequest, Intent, Execution, ExecutionStepResult, PlannedStep } from '../intent/types.js';
|
||||
import { nonceService } from '../nonce-service/nonce-service.js';
|
||||
import { getAdapter } from '../chain-adapters/get-adapter.js';
|
||||
import { recordAudit } from '../audit/audit-store.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const intents = new Map<string, Intent>();
|
||||
const executions = new Map<string, Execution>();
|
||||
const intentIdByKey = new Map<string, string>(); // idempotency_key -> intent_id
|
||||
|
||||
export function createIntent(request: IntentRequest): Intent {
|
||||
const idempotencyKey = request.idempotency_key;
|
||||
if (idempotencyKey && intentIdByKey.has(idempotencyKey)) {
|
||||
const existing = intents.get(intentIdByKey.get(idempotencyKey)!);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const result = validateAndPlan(request);
|
||||
if (!result.valid) {
|
||||
throw new Error(result.error ?? 'Validation failed');
|
||||
}
|
||||
|
||||
const intentId = `intent-${uuidv4()}`;
|
||||
const now = new Date().toISOString();
|
||||
const intent: Intent = {
|
||||
intent_id: intentId,
|
||||
status: 'planned',
|
||||
request,
|
||||
planned_steps: result.planned_steps,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
intents.set(intentId, intent);
|
||||
if (idempotencyKey) intentIdByKey.set(idempotencyKey, intentId);
|
||||
return intent;
|
||||
}
|
||||
|
||||
export function getIntent(intentId: string): Intent | undefined {
|
||||
return intents.get(intentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute planned steps: for MVP we simulate submission (no real wallet/signer).
|
||||
* In production EO would:
|
||||
* - Use real EVM/Tezos signers (wallet service or HSM)
|
||||
* - Replace placeholder txs with adapter.sendTransaction(signedTxHex)
|
||||
* - Plug in bridge-specific executors (IBridgeExecutor) for CCIP, Wrap, AlltraAdapter
|
||||
* - Implement retries, timeouts, idempotency keys per hop
|
||||
*/
|
||||
export async function executeIntent(intentId: string): Promise<Execution> {
|
||||
const intent = intents.get(intentId);
|
||||
if (!intent) throw new Error('Intent not found');
|
||||
if (intent.status !== 'planned') {
|
||||
const existing = executions.get(`${intentId}-exec`);
|
||||
if (existing) return existing;
|
||||
throw new Error(`Intent not in planned state: ${intent.status}`);
|
||||
}
|
||||
|
||||
const executionId = `exec-${uuidv4()}`;
|
||||
const now = new Date().toISOString();
|
||||
const steps: ExecutionStepResult[] = intent.planned_steps.map((s) => ({
|
||||
step_index: s.step_index,
|
||||
step_type: s.step_type,
|
||||
chain_id: s.chain_id,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
const execution: Execution = {
|
||||
execution_id: executionId,
|
||||
intent_id: intentId,
|
||||
status: 'submitting',
|
||||
submitted_txs: [],
|
||||
steps,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
executions.set(executionId, execution);
|
||||
executions.set(`${intentId}-exec`, execution);
|
||||
intent.status = 'executing';
|
||||
intent.updated_at = now;
|
||||
|
||||
// MVP: use placeholder when WALLET_ADDRESS not set. In production, set WALLET_ADDRESS and SIGNER_ENABLED=true.
|
||||
const wallet = process.env.WALLET_ADDRESS || '0x0000000000000000000000000000000000000001';
|
||||
const usePlaceholder = !process.env.WALLET_ADDRESS;
|
||||
if (process.env.SIGNER_ENABLED === 'true' && usePlaceholder) {
|
||||
throw new Error('SIGNER_ENABLED=true requires WALLET_ADDRESS to be set');
|
||||
}
|
||||
const lane = 'default';
|
||||
|
||||
for (const step of intent.planned_steps) {
|
||||
const adapter = getAdapter(step.chain_id);
|
||||
const nonce = nonceService.getNextNonce(step.chain_id, wallet, lane);
|
||||
// In production: build and sign tx, then adapter.sendTransaction(signedTxHex)
|
||||
const placeholderTxHash = `0x${Buffer.from(`${executionId}-${step.step_index}`).toString('hex').padEnd(64, '0')}`;
|
||||
nonceService.trackPending(step.chain_id, wallet, lane, placeholderTxHash);
|
||||
|
||||
execution.submitted_txs.push({ step_index: step.step_index, chain_id: step.chain_id, tx_hash: placeholderTxHash });
|
||||
const stepResult = execution.steps.find((s) => s.step_index === step.step_index);
|
||||
if (stepResult) {
|
||||
stepResult.tx_hash = placeholderTxHash;
|
||||
stepResult.status = 'submitted';
|
||||
}
|
||||
}
|
||||
|
||||
execution.status = 'completed';
|
||||
execution.updated_at = new Date().toISOString();
|
||||
intent.updated_at = execution.updated_at;
|
||||
intent.status = 'completed';
|
||||
|
||||
recordAudit({
|
||||
intent_id: intentId,
|
||||
execution_id: executionId,
|
||||
created_at: execution.created_at,
|
||||
status: 'completed',
|
||||
hops: execution.steps.map((s) => ({
|
||||
step_index: s.step_index,
|
||||
chain_id: s.chain_id,
|
||||
action: s.step_type,
|
||||
tx_hash: s.tx_hash,
|
||||
status: s.status ?? 'submitted',
|
||||
})),
|
||||
});
|
||||
|
||||
return execution;
|
||||
}
|
||||
|
||||
export function getExecution(executionId: string): Execution | undefined {
|
||||
return executions.get(executionId);
|
||||
}
|
||||
|
||||
export function getExecutionByIntent(intentId: string): Execution | undefined {
|
||||
return executions.get(`${intentId}-exec`);
|
||||
}
|
||||
8
multi-chain-execution/src/index.ts
Normal file
8
multi-chain-execution/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './chain-adapters/index.js';
|
||||
export * from './intent/types.js';
|
||||
export { createIntent, getIntent, executeIntent, getExecution, getExecutionByIntent } from './eo/execution-orchestrator.js';
|
||||
export { validateAndPlan } from './trpe/trpe.js';
|
||||
export { nonceService } from './nonce-service/nonce-service.js';
|
||||
export { buildCommitment, buildMerkleRoot, buildMerkleProof, hashLeaf, type CommitmentLeaf, type CommitmentResult } from './mirroring/merkle-commitment.js';
|
||||
export { saveCommit, getCommit, getProof } from './mirroring/mirror-store.js';
|
||||
export type { StoredCommit } from './mirroring/mirror-store.js';
|
||||
56
multi-chain-execution/src/intent/types.ts
Normal file
56
multi-chain-execution/src/intent/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type StepType = 'transfer' | 'swap' | 'bridge' | 'message_send' | 'message_receive' | 'mint' | 'burn';
|
||||
|
||||
export interface IntentRequest {
|
||||
type: string;
|
||||
chain_from: number;
|
||||
chain_to: number;
|
||||
asset_in: string;
|
||||
asset_out: string;
|
||||
amount: string;
|
||||
max_slippage_bps?: number;
|
||||
ttl_ms?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotency_key?: string;
|
||||
destination_tezos_address?: string;
|
||||
max_total_fees?: string;
|
||||
prefer_non_custodial?: boolean;
|
||||
require_audit_proof?: boolean;
|
||||
/** When chain_to is Tezos (1729), optional route plan to map to execution steps */
|
||||
route_plan?: { hops: Array<{ chain: string; action: string; chain_id?: number }> };
|
||||
}
|
||||
|
||||
export interface PlannedStep {
|
||||
step_index: number;
|
||||
step_type: StepType;
|
||||
chain_id: number;
|
||||
preconditions?: string[];
|
||||
postconditions?: string[];
|
||||
}
|
||||
|
||||
export interface Intent {
|
||||
intent_id: string;
|
||||
status: 'created' | 'planned' | 'executing' | 'completed' | 'failed';
|
||||
request: IntentRequest;
|
||||
planned_steps: PlannedStep[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionStepResult {
|
||||
step_index: number;
|
||||
step_type: StepType;
|
||||
chain_id: number;
|
||||
tx_hash?: string;
|
||||
status: 'pending' | 'submitted' | 'confirmed' | 'finalized' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Execution {
|
||||
execution_id: string;
|
||||
intent_id: string;
|
||||
status: 'pending' | 'submitting' | 'completed' | 'failed';
|
||||
submitted_txs: { step_index: number; chain_id: number; tx_hash: string }[];
|
||||
steps: ExecutionStepResult[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
4
multi-chain-execution/src/main.ts
Normal file
4
multi-chain-execution/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Entry point for multi-chain execution API server.
|
||||
*/
|
||||
import './api/server.js';
|
||||
108
multi-chain-execution/src/mirroring/merkle-commitment.ts
Normal file
108
multi-chain-execution/src/mirroring/merkle-commitment.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Merkle commitment builder for mirroring.
|
||||
* Builds tree over leaves: tx_hash, block_number, receipt_root_or_logs_bloom, payload_hash, sal_journal_hash.
|
||||
* Output: root, range [startBlock, endBlock], chain_id, schema_version.
|
||||
*/
|
||||
|
||||
import { keccak256 } from 'ethers';
|
||||
|
||||
export interface CommitmentLeaf {
|
||||
chainId: number;
|
||||
txHash: string;
|
||||
blockNumber: bigint;
|
||||
receiptRootOrLogsBloom: string;
|
||||
normalizedEventPayloadHash: string;
|
||||
salJournalEntryHash: string | null;
|
||||
}
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Canonical leaf hash for commitment (per event-schema-v1).
|
||||
*/
|
||||
export function hashLeaf(leaf: CommitmentLeaf): string {
|
||||
const payload = [
|
||||
leaf.chainId.toString(16),
|
||||
leaf.txHash.toLowerCase(),
|
||||
leaf.blockNumber.toString(16),
|
||||
leaf.receiptRootOrLogsBloom.toLowerCase(),
|
||||
leaf.normalizedEventPayloadHash.toLowerCase(),
|
||||
leaf.salJournalEntryHash?.toLowerCase() ?? '',
|
||||
].join('|');
|
||||
return keccak256(Buffer.from(payload, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Merkle root from leaf hashes (simple pair-wise hash up).
|
||||
*/
|
||||
export function buildMerkleRoot(leafHashes: string[]): string {
|
||||
if (leafHashes.length === 0) {
|
||||
return keccak256(Buffer.from('empty'));
|
||||
}
|
||||
let level = leafHashes.map((h) => h.toLowerCase());
|
||||
while (level.length > 1) {
|
||||
const next: string[] = [];
|
||||
for (let i = 0; i < level.length; i += 2) {
|
||||
const left = level[i];
|
||||
const right = i + 1 < level.length ? level[i + 1] : left;
|
||||
next.push(keccak256(Buffer.from(left.slice(2) + right.slice(2), 'hex')));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return level[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Merkle proof for a leaf index.
|
||||
*/
|
||||
export function buildMerkleProof(leafHashes: string[], index: number): string[] {
|
||||
if (index < 0 || index >= leafHashes.length) return [];
|
||||
const proof: string[] = [];
|
||||
let level = leafHashes.map((h) => h.toLowerCase());
|
||||
let idx = index;
|
||||
while (level.length > 1) {
|
||||
const next: string[] = [];
|
||||
const siblingIdx = idx % 2 === 0 ? idx + 1 : idx - 1;
|
||||
if (siblingIdx >= 0 && siblingIdx < level.length) {
|
||||
proof.push(level[siblingIdx]);
|
||||
}
|
||||
for (let i = 0; i < level.length; i += 2) {
|
||||
const left = level[i];
|
||||
const right = i + 1 < level.length ? level[i + 1] : left;
|
||||
next.push(keccak256(Buffer.from(left.slice(2) + right.slice(2), 'hex')));
|
||||
}
|
||||
level = next;
|
||||
idx = Math.floor(idx / 2);
|
||||
}
|
||||
return proof;
|
||||
}
|
||||
|
||||
export interface CommitmentResult {
|
||||
root: string;
|
||||
startBlock: number;
|
||||
endBlock: number;
|
||||
chainId: number;
|
||||
schemaVersion: number;
|
||||
leafCount: number;
|
||||
leafHashes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build commitment from leaves. Returns root and metadata for submitCommit.
|
||||
*/
|
||||
export function buildCommitment(leaves: CommitmentLeaf[], chainId: number): CommitmentResult {
|
||||
const leafHashes = leaves.map(hashLeaf);
|
||||
const root = buildMerkleRoot(leafHashes);
|
||||
const blockNumbers = leaves.map((l) => Number(l.blockNumber));
|
||||
const startBlock = Math.min(...blockNumbers);
|
||||
const endBlock = Math.max(...blockNumbers);
|
||||
return {
|
||||
root,
|
||||
startBlock,
|
||||
endBlock,
|
||||
chainId,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
leafCount: leaves.length,
|
||||
leafHashes,
|
||||
};
|
||||
}
|
||||
56
multi-chain-execution/src/mirroring/mirror-store.ts
Normal file
56
multi-chain-execution/src/mirroring/mirror-store.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* In-memory store for mirror commits (MVP). In production use DB + object storage.
|
||||
*/
|
||||
import { buildMerkleProof } from './merkle-commitment.js';
|
||||
|
||||
export interface StoredCommit {
|
||||
commitId: string;
|
||||
chainId: number;
|
||||
startBlock: number;
|
||||
endBlock: number;
|
||||
root: string;
|
||||
uri: string;
|
||||
timestamp: number;
|
||||
leafHashes: string[];
|
||||
leavesByTxHash: Map<string, { leafIndex: number; leafData: unknown }>;
|
||||
publicChainTxHashes: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const commits = new Map<string, StoredCommit>();
|
||||
|
||||
export function saveCommit(c: StoredCommit): void {
|
||||
commits.set(c.commitId, c);
|
||||
}
|
||||
|
||||
export function getCommit(commitId: string): StoredCommit | undefined {
|
||||
return commits.get(commitId);
|
||||
}
|
||||
|
||||
export function getProof(chainId: number, txHash: string): {
|
||||
commitId: string;
|
||||
leafData: unknown;
|
||||
leafIndex: number;
|
||||
leafHash: string;
|
||||
proof: string[];
|
||||
root: string;
|
||||
publicChainTxHashes: string[];
|
||||
} | null {
|
||||
const txHashLower = txHash.toLowerCase();
|
||||
for (const c of commits.values()) {
|
||||
if (c.chainId !== chainId) continue;
|
||||
const entry = c.leavesByTxHash.get(txHashLower);
|
||||
if (!entry) continue;
|
||||
const proof = buildMerkleProof(c.leafHashes, entry.leafIndex);
|
||||
return {
|
||||
commitId: c.commitId,
|
||||
leafData: entry.leafData,
|
||||
leafIndex: entry.leafIndex,
|
||||
leafHash: c.leafHashes[entry.leafIndex],
|
||||
proof,
|
||||
root: c.root,
|
||||
publicChainTxHashes: c.publicChainTxHashes,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
47
multi-chain-execution/src/nonce-service/nonce-service.ts
Normal file
47
multi-chain-execution/src/nonce-service/nonce-service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Centralized nonce allocation per (chain, wallet, lane).
|
||||
* Tracks pending txs; reclaims nonce on drop/timeout.
|
||||
*/
|
||||
|
||||
function key(chainId: number, wallet: string, lane: string): string {
|
||||
return `${chainId}:${wallet.toLowerCase()}:${lane}`;
|
||||
}
|
||||
|
||||
const pendingByKey = new Map<string, Set<string>>();
|
||||
const nextNonceByKey = new Map<string, number>();
|
||||
|
||||
export class NonceService {
|
||||
getNextNonce(chainId: number, wallet: string, lane: string): number {
|
||||
const k = key(chainId, wallet, lane);
|
||||
const next = nextNonceByKey.get(k) ?? 0;
|
||||
nextNonceByKey.set(k, next + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
trackPending(chainId: number, wallet: string, lane: string, txHash: string): void {
|
||||
const k = key(chainId, wallet, lane);
|
||||
let set = pendingByKey.get(k);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
pendingByKey.set(k, set);
|
||||
}
|
||||
set.add(txHash.toLowerCase());
|
||||
}
|
||||
|
||||
releasePending(chainId: number, wallet: string, lane: string, txHash: string): void {
|
||||
const k = key(chainId, wallet, lane);
|
||||
const set = pendingByKey.get(k);
|
||||
if (set) set.delete(txHash.toLowerCase());
|
||||
}
|
||||
|
||||
setNextNonce(chainId: number, wallet: string, lane: string, nonce: number): void {
|
||||
nextNonceByKey.set(key(chainId, wallet, lane), nonce);
|
||||
}
|
||||
|
||||
pendingCount(chainId: number, wallet: string, lane: string): number {
|
||||
const set = pendingByKey.get(key(chainId, wallet, lane));
|
||||
return set?.size ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const nonceService = new NonceService();
|
||||
82
multi-chain-execution/src/trpe/trpe.ts
Normal file
82
multi-chain-execution/src/trpe/trpe.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Transaction Router + Policy Engine (TRPE).
|
||||
* Validates intents, selects execution path, enforces limits.
|
||||
*/
|
||||
|
||||
import type { IntentRequest, PlannedStep } from '../intent/types.js';
|
||||
import { getSupportedChainIds } from '../chain-adapters/config.js';
|
||||
|
||||
const MAX_AMOUNT = 1e30;
|
||||
const DEFAULT_MAX_SLIPPAGE_BPS = 500;
|
||||
const DEFAULT_TTL_MS = 300_000;
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
planned_steps: PlannedStep[];
|
||||
}
|
||||
|
||||
export function validateAndPlan(request: IntentRequest): ValidationResult {
|
||||
const chainFrom = request.chain_from;
|
||||
const chainTo = request.chain_to;
|
||||
const supported = getSupportedChainIds();
|
||||
if (!supported.includes(chainFrom) || !supported.includes(chainTo)) {
|
||||
return { valid: false, error: 'Unsupported chain', planned_steps: [] };
|
||||
}
|
||||
|
||||
const amount = parseFloat(request.amount);
|
||||
if (isNaN(amount) || amount <= 0 || amount > MAX_AMOUNT) {
|
||||
return { valid: false, error: 'Invalid amount', planned_steps: [] };
|
||||
}
|
||||
|
||||
const maxSlippageBps = request.max_slippage_bps ?? DEFAULT_MAX_SLIPPAGE_BPS;
|
||||
if (maxSlippageBps < 0 || maxSlippageBps > 10000) {
|
||||
return { valid: false, error: 'Invalid max_slippage_bps', planned_steps: [] };
|
||||
}
|
||||
|
||||
const steps: PlannedStep[] = [];
|
||||
const TEZOS_CHAIN_ID = 1729;
|
||||
const chainLabelToId: Record<string, number> = {
|
||||
CHAIN138: 138,
|
||||
ALL_MAINNET: 651940,
|
||||
HUB_EVM: 1,
|
||||
TEZOS: TEZOS_CHAIN_ID,
|
||||
};
|
||||
|
||||
if (request.route_plan?.hops?.length && chainTo === TEZOS_CHAIN_ID) {
|
||||
for (let i = 0; i < request.route_plan.hops.length; i++) {
|
||||
const h = request.route_plan.hops[i];
|
||||
const chainId = h.chain_id ?? chainLabelToId[h.chain] ?? (h.chain === 'CHAIN138' ? 138 : h.chain === 'ALL_MAINNET' ? 651940 : h.chain === 'TEZOS' ? TEZOS_CHAIN_ID : 1);
|
||||
const stepType = h.action === 'SWAP' ? 'swap' : h.action === 'BRIDGE' ? 'bridge' : h.action === 'TRANSFER' ? 'transfer' : 'message_send';
|
||||
steps.push({
|
||||
step_index: i,
|
||||
step_type: stepType as 'swap' | 'bridge' | 'transfer' | 'message_send' | 'message_receive',
|
||||
chain_id: chainId,
|
||||
});
|
||||
}
|
||||
return { valid: true, planned_steps: steps };
|
||||
}
|
||||
|
||||
if (chainFrom === chainTo) {
|
||||
if (request.asset_in === request.asset_out) {
|
||||
steps.push({ step_index: 0, step_type: 'transfer', chain_id: chainFrom });
|
||||
} else {
|
||||
steps.push({ step_index: 0, step_type: 'swap', chain_id: chainFrom });
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
step_index: 0,
|
||||
step_type: 'message_send',
|
||||
chain_id: chainFrom,
|
||||
postconditions: ['message_sent'],
|
||||
});
|
||||
steps.push({
|
||||
step_index: 1,
|
||||
step_type: 'message_receive',
|
||||
chain_id: chainTo,
|
||||
preconditions: ['message_sent'],
|
||||
});
|
||||
}
|
||||
|
||||
return { valid: true, planned_steps: steps };
|
||||
}
|
||||
Reference in New Issue
Block a user